Deliver BAT-001: bootstrap trex-core + port slot pure domain

Implement REQ-001~004 (dcy-001): scaffold the trex-core package
(pyproject, trex_core/, tests/), port the slot pure domain into
trex_core.domain.slot with import rewrites, add the EXPECTED_SLOT_FIELDS
contract guard (test_contract), and port the state/service unit tests.

Verification: 56 passed, ruff clean, import sweep shows zero external
deps, public __all__ exposes no infra symbols, contract negative case
reproduced. Align project-structure/runtime-flows baselines to the real
tree. Sync SDLC tracking: REQ-001~004 → Implemented, BAT-001 →
release-candidate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:16:07 +09:00
co-authored by Claude Opus 4.8
parent 8b23b89977
commit 20498aa4ce
31 changed files with 2044 additions and 21 deletions
+5 -1
View File
@@ -1 +1,5 @@
/temp/
/temp/
/.venv/
__pycache__/
*.egg-info/
.pytest_cache/
@@ -0,0 +1,30 @@
# BAT-001 Design
- Status: confirmed
- Batch: BAT-001
## Architecture Summary
- trex-core 를 `trex_core` import 패키지(배포명 `trex-core`)로 부트스트랩하고, 첫 도메인으로 slot 순수 도메인을 `trex_core/domain/slot/` 에 둔다. 도메인은 저장소를 `SlotRepository` Protocol 로 추상화하고 구현체(InMemorySlotRepository)를 주입받는 storage-agnostic 구조다. 인프라(향후 PostgreSQL/CLI/config)는 도메인을 의존할 수 있으나 그 역은 금지한다.
## Changed Areas
- Docs: docs/project-structure.md, docs/runtime-flows.md (REQ-004 정합), docs/data-model.md·docs/interface-contract.md (계약 기준 참조)
- Modules: pyproject.toml, trex_core/__init__.py, trex_core/domain/__init__.py, trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py
- Interfaces: 공개 API = SlotState, Slot, SlotCapacity, SlotTransitionRecord, SlotRepository, SlotService, 예외 4종, is_allowed_transition, SLOT_ADVISORY_LOCK_ID, ALLOWED_TRANSITIONS
## Key Decisions
1. 네임스페이스 `trex_core.domain.slot`(dcy-001 OQ-1). 모듈 간 import 를 모두 `trex_core.domain.slot.*` 로 통일하고 `app.domain.slot` 잔존을 0으로 만든다.
2. 컬럼 계약 박제 = 계약 테스트(dcy-001 OQ-2). 도메인에 `EXPECTED_SLOT_FIELDS` 표준 필드 집합을 선언하고 `test_contract.py` 가 `{f.name for f in dataclasses.fields(Slot)}` 와의 일치를 단언. 타입 단언은 하지 않고 타입 매핑은 문서(부록 A/data-model)에 둔다.
3. 공개 API 는 인프라 심볼(`PostgreSQLSlotRepository`, `check_slot_domain_schema_drift`)을 노출하지 않는다 — 애초 `__init__` 이 이를 export 하지 않으므로 docstring 정리만 수행.
4. `SLOT_ADVISORY_LOCK_ID` 는 순수 도메인에 잔류(현행 유지). 영속화 계층 신설 시 재배치 여부는 후속 반복(dcy-001 OQ-3 인접) 판단.
## Edge Cases
- `EXPECTED_SLOT_FIELDS` 와 `Slot` 필드 불일치 시 계약 테스트가 실패해야 한다(음성 케이스 의도적 검증).
- `closed→idle`/`pending_buy→idle` 복구 시 점유 필드 초기화 + 재진입 카운트 +1 불변식 보존 확인(이식 테스트 범위).
- in-memory repo 의 threading.Lock 이 advisory lock 의미를 모사하므로, 동시성 관련 테스트도 DB 없이 성립.
## Architecture Impact
- both (project-structure + runtime-flows)
## Reference Doc Update Plan
- project-structure.md: planned 트리·모듈 책임의 `(planned)` 표기를 실제 `trex_core/` 구조로 갱신(REQ-004). 네임스페이스는 이미 `trex_core/` 로 정정 완료, 잔여는 실재 반영.
- runtime-flows.md: in-memory 기반 도메인 흐름(preallocate/transit)이 실제 코드와 일치하는지 확인하고 "미구현/planned" 성격 표기를 정정(REQ-004).
@@ -0,0 +1,35 @@
# BAT-001 Implementation
- Status: completed
- Batch: BAT-001
## Plan Summary
- REQ-001 부트스트랩 → REQ-002 slot 순수 도메인 이식 → REQ-003 테스트·계약 가드 → REQ-004 baseline 정합을 순차 구현했다. trex-orbit `app/domain/slot/` 의 순수 6개 모듈 + `__init__` 을 `trex_core.domain.slot` 로 복사하고 import 를 전면 치환, 테스트 2종 이식 + 계약 테스트 신설.
## Changed Files
- 신규(REQ-001): `pyproject.toml`, `trex_core/__init__.py`, `trex_core/domain/__init__.py`, `tests/__init__.py`, `tests/domain/__init__.py`, `tests/domain/slot/__init__.py`
- 신규(REQ-002): `trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py`
- import 경로 `app.domain.slot.*` → `trex_core.domain.slot.*` 전면 치환, `__init__` docstring 순수 도메인 기준 정리(인프라 export 없음 확인)
- 신규(REQ-003): `tests/domain/slot/{test_state,test_service,test_contract}.py`, `models.py` 에 `EXPECTED_SLOT_FIELDS` 선언
- 갱신(REQ-004): `docs/project-structure.md`, `docs/runtime-flows.md` planned→실제 정합
## Execution Log
1. `pyproject.toml`(name=trex-core, requires-python>=3.12, setuptools, dev extras=pytest·ruff) + 패키지 레이아웃 생성.
2. 순수 6개 모듈 + `__init__` 이식, import 치환, `EXPECTED_SLOT_FIELDS` 추가.
3. `test_state.py`·`test_service.py` 이식(미사용 import 정리, `t1.start()/join()` 멀티스테이트먼트 분리), `test_contract.py` 신설.
4. `.venv` 생성 후 `pip install -e .[dev]` (PEP 668 외부관리 환경이라 venv 사용).
5. `pytest`, `ruff check .`, 공개 API import, import 스윕, 계약 음성 케이스 실행.
6. `docs/project-structure.md`·`docs/runtime-flows.md` 정합(REQ-004).
## Validation
- `pytest -q`: **56 passed** (test_state 18 + test_service 35 + test_contract 3, 근사).
- `ruff check .`: **All checks passed**.
- 공개 API import: 15개 심볼 전부 import 성공(`SlotState`...`EXPECTED_SLOT_FIELDS`).
- import 스윕: `trex_core/domain/slot/*.py` 에 stdlib + `trex_core.domain.slot` 외 외부 의존 **0**.
- 공개 `__all__`: `PostgreSQL`/`schema` 인프라 심볼 노출 **0**.
- 계약 음성 케이스: `Slot` 에 rogue 필드 추가 시 계약 일치 단언이 False 로 떨어짐(실패 재현 확인).
## Remaining Risks
- R-1 영속화 부재(in-memory 한정) — 후속 Discovery 인계.
- R-2 DB-side 컬럼 드리프트 검사 미도입 — 영속화 REQ 인계(계약 테스트는 코드 측 가드 확보).
- 환경: 시스템 Python 3.14.4 에서 venv 로 검증(requires-python>=3.12 충족). CI 표준 인터프리터 고정은 후속.
@@ -0,0 +1,32 @@
# BAT-001: trex-core 베이스 부트스트랩 + slot 순수 도메인 시드
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-001
- Included REQ: REQ-001, REQ-002, REQ-003, REQ-004
- Excluded REQ: 없음
## Summary
- dcy-001 의 단일 가치(trex-core 베이스 수립 + slot 순수 도메인 시드)를 하나의 delivery 단위로 묶는다. 부트스트랩(REQ-001) → 도메인 이식(REQ-002) → 테스트·계약 가드(REQ-003) → baseline 정합(REQ-004)이 같은 iteration·같은 모듈 경로(`trex_core/domain/slot`)·같은 검증 흐름(`pytest`)을 공유한다.
## Scope
- In Scope:
- REQ-001: `pyproject.toml`(배포명 `trex-core`) + `trex_core/`·`trex_core/domain/` 레이아웃 + pytest/ruff 설정.
- REQ-002: 순수 6개 모듈 + `__init__` 을 `trex_core.domain.slot` 로 이식, 공개 API 인프라 심볼 없이 노출.
- REQ-003: 단위테스트(`test_state`/`test_service`) 이식 green + 계약 가드(`EXPECTED_SLOT_FIELDS` + `test_contract`).
- REQ-004: 이식 후 `docs/project-structure.md`·`docs/runtime-flows.md` planned→실제 정합.
- Out of Scope:
- PostgreSQL 구현체/ SQL DDL/ `db init`/ schema drift 감지.
- `trex slot` CLI, config 해석, coordinator 연동, archive/prune.
- trex-orbit 수정 및 trex-orbit↔trex-core 코드 수렴.
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- Source Discovery 는 dcy-001 단일. 모든 REQ 가 dcy-001 FR-1~FR-5 에서 분해됨.
- 공통 리스크: 영속화 부재(in-memory 한정, dcy-001 R-1), 컬럼 계약 드리프트(REQ-003 계약 테스트로 완화, R-2), 이중 진실(병행, R-3).
- 네임스페이스 `trex_core.domain.slot`(dcy-001 OQ-1), 컬럼 계약 박제 = 계약 테스트(OQ-2). DB-side 드리프트 검사는 영속화 반복 인계.
@@ -0,0 +1,42 @@
# BAT-001 Planning
- Status: confirmed
- Batch: BAT-001
- Profile: standard
## Included REQ
- REQ-001 (Installation): trex-core Python 프로젝트 부트스트랩
- REQ-002 (Interface): slot 순수 도메인 이식 및 공개 API 확정
- REQ-003 (Testing): slot 도메인 단위테스트 이식 및 컬럼 계약 가드
- REQ-004 (Documentation): baseline 문서 planned→실제 정합
## Delivery Plan
- 선형 의존을 따라 순차 구현한다.
1. REQ-001: `pyproject.toml`(name=`trex-core`, requires-python>=3.12, setuptools, dev extras=pytest·ruff) + `trex_core/__init__.py`, `trex_core/domain/__init__.py` + `tests/` 스캐폴딩 + ruff/pytest 설정.
2. REQ-002: trex-orbit `app/domain/slot/` 의 순수 6개 모듈 + `__init__` 을 `trex_core/domain/slot/` 로 복사하고 모듈 간 import 를 `trex_core.domain.slot.*` 로 치환, `__init__` docstring 정리(인프라 export 없음 확인).
3. REQ-003: `tests/domain/slot/test_state.py`·`test_service.py` 이식, `trex_core/domain/slot` 에 `EXPECTED_SLOT_FIELDS` 선언, `test_contract.py` 신설(`fields(Slot)` 일치 단언).
4. REQ-004: 위 산출물 확정 후 `docs/project-structure.md`·`docs/runtime-flows.md` 의 planned 표기를 실제 구조로 정합.
## Dependencies
- Internal:
- REQ-002 는 REQ-001(패키지 레이아웃) 선행 필요.
- REQ-003 은 REQ-002(도메인 코드) 선행 필요.
- REQ-004 는 REQ-002(실제 구조 확정) 선행 필요.
- External:
- 없음(외부 서비스·DB 미사용). 개발 의존성: pytest, ruff(dev extras).
## Design Gate
- Design Required Before Implementation: yes
- Reason: 신규 패키지 네임스페이스(`trex_core`) 도입 + 공개 API 표면 확정 + baseline 구조/런타임 문서 정합이 함께 걸린다(구조·인터페이스 영향). planning 만으로는 import 경계·공개 API·계약 가드 위치 결정이 드리프트할 수 있어 design.md 를 같은 턴에 작성한다.
## Milestones
1. M1 — REQ-001: 부트스트랩 완료(`pip install -e .[dev]` + `pytest` 수집 성공 + `ruff check` 동작).
2. M2 — REQ-002: 도메인 이식 + 공개 API import 성공 + import 스윕 외부 의존 0.
3. M3 — REQ-003: 이식 테스트 2종 green + 계약 테스트 green(필드 변경 시 실패 재현).
4. M4 — REQ-004: baseline 문서 정합(`app/` 잔존 0, 트리/흐름 실제 일치).
## Risks
- R-1 (Medium): 영속화 부재 → in-memory repo 한정 구동. 대응: 이번 batch 목표를 "도메인 코어 시드 + 테스트 green"으로 한정, 영속화는 후속 Discovery.
- R-2 (Medium): 컬럼 계약 드리프트. 대응: REQ-003 계약 테스트(`EXPECTED_SLOT_FIELDS`)로 자동 강제, DB-side 검사 인계.
- R-3 (Low): trex-orbit·trex-core 이중 진실. 대응: 의도된 임시 비용으로 수용, 수렴은 별도 Discovery.
- R-4 (Low): import 경로 치환 누락(`app.domain.slot` 잔존). 대응: 이식 후 grep 으로 잔존 import 0 확인(REQ-002 AC).
@@ -0,0 +1,46 @@
# BAT-001 Verification
- Status: confirmed
- Batch: BAT-001
## Acceptance Mapping
- REQ-001 (Installation):
- AC1 설치/import → `pip install -e .[dev]` 성공 + `import trex_core` OK (.venv, Python 3.14.4 ≥ 3.12). **PASS**
- AC2 pytest 수집 → `pytest -q` 56 passed (수집/실행 정상). **PASS**
- AC3 pyproject 메타 → name=`trex-core`, requires-python>=3.12, setuptools, dev extras=pytest·ruff 확인. **PASS**
- AC4 ruff 동작 → `ruff check .` All checks passed. **PASS**
- REQ-002 (Interface):
- AC1 공개 API import → 15개 심볼(SlotState…EXPECTED_SLOT_FIELDS) import 성공. **PASS**
- AC2 외부 의존 0 → import 스윕 결과 stdlib + `trex_core.domain.slot` 외 import 없음. **PASS**
- AC3 인프라 심볼 비노출 → `__all__` 에 PostgreSQL/schema 심볼 0. **PASS**
- AC4 도메인 불변식 보존 → test_state(전이 14종) + test_service(advisory lock 직렬화·closed→idle 재진입+1·race 감지) 통과. **PASS**
- REQ-003 (Testing):
- AC1 이식 테스트 green → test_state + test_service 통과(56 passed 에 포함). **PASS**
- AC2 계약 테스트 green → test_contract `fields(Slot)==EXPECTED_SLOT_FIELDS` 통과. **PASS**
- AC3 필드 변경 시 실패 재현 → rogue 필드 추가 시 계약 일치 False 확인(live negative case). **PASS**
- AC4 타입 단언 배제 → test_contract 는 이름/존재만 단언, 타입 매핑은 부록 A/data-model 문서. **PASS**
- REQ-004 (Documentation):
- AC1 project-structure 정합 → `(planned)` 제거, 실제 `trex_core/domain/slot` 트리·모듈 책임 반영. **PASS**
- AC2 runtime-flows 정합 → preallocate/transit 흐름이 실제 코드 기반임을 Change Log 반영, 영속화 미구현 표기 유지(정확). **PASS**
- AC3 네임스페이스 일관 → `docs/project-structure.md`·`docs/runtime-flows.md` 에 `app/` 잔존 0(Change Log 정정 이력 1건 제외). **PASS**
## Evidence
- Test:
- `pytest -q` → `56 passed in 0.06s` (test_state / test_service / test_contract).
- 계약 음성 케이스: `dataclasses.make_dataclass` 로 rogue 필드 추가 → 계약 일치 단언 False.
- Manual:
- import 스윕 grep: `trex_core/domain/slot/*.py` 외부/인프라 import 0.
- `__all__` 점검: 인프라 심볼 0.
- baseline 문서 `app/` 잔존 grep: 정정 Change Log 1건만 매칭.
- Logs:
- run-batch-implementation 단계 실행 로그(implementation.md Validation 절) 참조.
## Baseline Sync Evidence (Architecture Impact = both)
- docs/project-structure.md: planned 트리·모듈 책임을 실제 `trex_core/` 구조로 갱신 + Change Log 기록(REQ-004).
- docs/runtime-flows.md: 도메인 흐름이 실제 코드 기반임을 반영 + Change Log 기록(REQ-004).
- design.md `Reference Doc Update Plan` 의 두 항목 모두 반영 완료.
## Result
- Ready for Release: true
- Blocking Issues:
- 없음 (모든 포함 REQ AC 가 evidence 와 연결됨. 잔여 리스크 R-1/R-2 는 후속 영속화 반복 인계 사항으로 비차단)
+1
View File
@@ -8,3 +8,4 @@
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
| --- | --- | --- | --- | --- | --- |
| BAT-001 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-004 | dcy-001 | [bat-001](./bat-001_20260603_trex-core-slot-domain-seed/) |
@@ -5,10 +5,10 @@
- 문서 ID: dcy-001_20260603_slot-pure-domain-migration
- 이슈명: trex-core 베이스 수립 및 slot 순수 도메인 이식
- 작성 시각(KST): 2026-06-03
- 마지막 갱신 시각(KST): 2026-06-03
- 마지막 갱신 시각(KST): 2026-06-03 (draft-req)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음
- 생성된 REQ 참조: REQ-001, REQ-002, REQ-003, REQ-004
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
+25 -16
View File
@@ -55,28 +55,36 @@
│ ├── project-structure.md
│ └── runtime-flows.md
├── CLAUDE.md # SDLC governance instructions
└── (planned)
├── pyproject.toml # 패키지 메타 + pytest/ruff 설정
├── trex_core/
│ └── domain/
│ └── slot/ # 이식 대상 순수 도메인
└── tests/
└── domain/slot/ # 도메인 단위 테스트
├── pyproject.toml # 패키지 메타(배포명 trex-core) + pytest/ruff 설정
├── trex_core/
│ ├── __init__.py
│ └── domain/
│ ├── __init__.py
│ └── slot/ # slot 순수 도메인 (BAT-001 이식)
│ ├── __init__.py # 공개 API (인프라 심볼 없음)
│ ├── state.py # SlotState 6상태 + 전이
│ ├── models.py # Slot/SlotCapacity/SlotTransitionRecord + EXPECTED_SLOT_FIELDS
│ ├── errors.py # 도메인 예외 4종
│ ├── repository.py # SlotRepository Protocol
│ ├── service.py # SlotService
│ └── in_memory_repository.py
└── tests/
└── domain/slot/ # test_state / test_service / test_contract
```
> 참고: 위 `(planned)` 항목은 slot 순수 도메인 이관 Batch 에서 생성될 예정 구조다(plan.md). 현재 저장소에는 아직 코드가 없고 governance/문서 스캐폴딩만 존재한다.
> 참고: `trex_core/`·`tests/`·`pyproject.toml` 은 BAT-001(dcy-001 FR-1~FR-5)에서 실제 생성되었다. 영속화·CLI·config 는 이번 범위 밖이다.
## Package / Module Responsibilities
- `trex_core/domain/slot/` (planned)
- 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델(`models.py`), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), 테스트용 in-memory 구현(`in_memory_repository.py`).
- 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용.
- `tests/domain/slot/` (planned)
- 상태머신/서비스 단위 테스트. InMemorySlotRepository 기반으로 실제 DB 없이 검증.
- `trex_core/domain/slot/`
- 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`).
- 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨).
- `tests/domain/slot/`
- 상태머신/서비스 단위 테스트(`test_state`, `test_service`) + 컬럼 계약 가드(`test_contract`). InMemorySlotRepository 기반으로 실제 DB 없이 검증.
- `docs/`
- SDLC baseline(구조/흐름/인터페이스/데이터 모델) 및 active index(discovery/srs/batches/releases).
- `pyproject.toml` (planned)
- 패키지 메타데이터, 의존성, pytest/ruff 설정.
- `pyproject.toml`
- 패키지 메타데이터(배포명 `trex-core`, `requires-python >=3.12`), dev 의존성(pytest/ruff), pytest/ruff 설정.
## Dependency Rules
@@ -94,7 +102,7 @@
- 영속화 구현체(PostgreSQL repository)와 SQL 스키마/마이그레이션은 이번 범위 밖 — 향후 도입.
- CLI / 설정(config) 표면 없음(trex-orbit 의 `trex slot` CLI 미이식).
- 패키지 네임스페이스는 `trex_core.domain.slot` 으로 확정(dcy-001 OQ-1, 배포명 `trex-core`).
- `pyproject.toml` 및 `trex_core/`, `tests/` 스캐폴딩은 이관 Batch 에서 생성 예정.
- 컬럼 계약 드리프트는 `EXPECTED_SLOT_FIELDS` + `test_contract.py` 로 강제(DB-side 검사는 영속화 반복 인계).
## Update Triggers
@@ -106,3 +114,4 @@
- 2026-06-03 :: bootstrap-baseline 으로 초기 구조 baseline 생성(mixed: trex-orbit 코드 관찰 + 이관 계획 선언).
- 2026-06-03 :: dcy-001 OQ-1 결정 반영 — planned 네임스페이스를 `app/` → `trex_core/` 로 정정(배포명 `trex-core`).
- 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004).
+1
View File
@@ -70,3 +70,4 @@
## Change Log
- 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준).
- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖).
@@ -0,0 +1,152 @@
# req-004: baseline 문서 planned→실제 정합
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: project maintainers
## Intent
- slot 순수 도메인 이식 이후, baseline 문서의 planned 표기를 실제 코드 구조와 일치시켜 Discovery/REQ/Batch 가 참조하는 cross-cutting baseline 의 정확성을 유지한다.
## Requirement
- 이식 완료 후 `docs/project-structure.md` 와 `docs/runtime-flows.md` 의 planned 표기(디렉터리 트리, 모듈 책임, `(planned)`/미구현 표기)를 실제 `trex_core/` 구조와 일치하도록 갱신한다.
## Acceptance Criteria
1. Given 이식 완료, When `docs/project-structure.md` 를 확인, Then `(planned)` 표시가 실제 생성 구조로 갱신되고 트리에 `trex_core/domain/slot` 이 실재 구조로 반영된다.
2. Given 이식 완료, When `docs/runtime-flows.md` 를 확인, Then in-memory 기반 도메인 흐름 설명이 실제 코드와 일치하고 "미구현" 성격 표기가 정정된다.
3. Given baseline 문서 전체, When 네임스페이스 표기를 확인, Then `trex_core` 로 일관되며 `app/` 잔존이 없다(정정 이력을 남기는 Change Log 항목은 예외).
## Impacted Area
- Docs: docs/project-structure.md, docs/runtime-flows.md
- Modules: 없음(문서 전용)
- Tests: 없음
## Notes
- dcy-001 FR-4. REQ-002 이식 완료 후 수행해야 의미가 있다(순서 의존).
- `docs/data-model.md`·`docs/interface-contract.md` 는 이미 도메인 계약 기준으로 작성돼 큰 변경이 없을 수 있으나, 불일치 발견 시 함께 정정한다.
- 출처: dcy-001 FR-4.
## Change Log
### CHG-20260603-01
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- dcy-001 FR-4 로부터 최초 생성(Proposed).
#### Intent
- 이식 후 baseline 문서 정합 요구사항을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added:
- project-structure 트리/Status 갱신, runtime-flows 흐름 정합, 네임스페이스 일관성 3개 기준
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/project-structure.md, docs/runtime-flows.md
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: REQ-002 이식 완료 후 batch verification 단계에서 수행
#### Validation Plan
- baseline 문서와 실제 `trex_core/` 트리 비교 + `app/` 잔존 grep 0(정정 이력 제외)으로 검증.
### CHG-20260603-02
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register 상태 Approved)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- none (batch 편성 전)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: REQ-002 이식 완료 후 batch verification 단계에서 수행
#### Validation Plan
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
### CHG-20260603-03
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate).
#### Intent
- BAT-001 verification.md 의 evidence(project-structure/runtime-flows 정합, `app/` 잔존 0)로 AC1~AC3 충족 → 구현 완료 동기화.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register Implemented), docs/project-structure.md, docs/runtime-flows.md
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- BAT-001 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: draft-release 로 release 편성
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
@@ -0,0 +1,154 @@
# req-001: trex-core Python 프로젝트 부트스트랩
- Status: Implemented
- Type: Installation
- Priority: High
- Owner: project maintainers
## Intent
- 빈 베이스인 trex-core 를 설치/테스트 가능한 Python 패키지로 세워, 이후 slot 순수 도메인 이식과 테스트가 가능한 기반을 만든다.
- 패키지 네임스페이스와 툴체인을 dcy-001 결정(`trex_core`, trex-orbit 동등 툴체인)에 맞춰 고정한다.
## Requirement
- `pyproject.toml`(배포명 `trex-core`, `requires-python >=3.12`, setuptools build backend, dev extras 에 `pytest`·`ruff`)과 패키지 레이아웃(`trex_core/__init__.py`, `trex_core/domain/__init__.py`), `tests/` 스캐폴딩, pytest/ruff 설정을 생성한다.
## Acceptance Criteria
1. Given 클린 가상환경, When `pip install -e .[dev]` 를 실행, Then 설치가 성공하고 `import trex_core` 가 가능하다.
2. Given 부트스트랩 완료 상태, When `pytest` 를 실행, Then 테스트 수집/실행이 에러 없이 동작한다(테스트 0개여도 수집 자체는 성공).
3. Given `pyproject.toml`, When 내용을 확인, Then `name = "trex-core"`, `requires-python >=3.12`, setuptools build-backend, dev extras 에 `pytest`·`ruff` 가 포함된다.
4. Given 부트스트랩 완료 상태, When `ruff check .` 를 실행, Then 린트가 설정대로 동작한다(설정 부재 에러 없음).
## Impacted Area
- Docs: docs/project-structure.md (레이아웃 baseline — REQ-004 에서 정합 갱신)
- Modules: pyproject.toml, trex_core/__init__.py, trex_core/domain/__init__.py
- Tests: tests/ 디렉터리 스캐폴딩(`tests/__init__.py`, `tests/domain/slot/__init__.py`)
## Notes
- 네임스페이스 `trex_core` 는 dcy-001 OQ-1 결정. 툴체인 동등(Python >=3.12 + setuptools + pytest + ruff)은 dcy-001 confirm 확정.
- 본 REQ 는 REQ-002(도메인 이식)·REQ-003(테스트)의 선행 조건이다.
- 출처: dcy-001 FR-1.
## Change Log
### CHG-20260603-01
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- dcy-001 FR-1 로부터 최초 생성(Proposed).
#### Intent
- trex-core 베이스 부트스트랩 요구사항을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added:
- 설치 성공 및 import, pytest 수집, pyproject 메타, ruff 동작 4개 기준
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/project-structure.md (레이아웃 baseline)
- Modules:
- pyproject.toml, trex_core 패키지 스캐폴딩
- Tests:
- tests/ 스캐폴딩
#### Delivery Trace Delta
- Batches:
- none (미배치)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 로 Approved 전환 후 batch 편성
#### Validation Plan
- 클린 환경에서 `pip install -e .[dev]` + `pytest` + `ruff check` 실행 결과로 검증.
### CHG-20260603-02
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register 상태 Approved)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- none (batch 편성 전)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
#### Validation Plan
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
### CHG-20260603-03
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate).
#### Intent
- BAT-001 verification.md 의 acceptance mapping + evidence(설치/pytest/ruff/메타)로 AC1~AC4 충족 → 구현 완료 동기화.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register Implemented)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- BAT-001 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: draft-release 로 release 편성
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
@@ -0,0 +1,154 @@
# req-002: slot 순수 도메인 이식 및 공개 API 확정
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: project maintainers
## Intent
- 검증된 slot 상태머신/서비스 계약을 인프라에 오염되지 않은 storage-agnostic 코어로 trex-core 에 이식해, trex-core 의 표준 도메인 작성 패턴(순수 도메인 + Protocol 저장소 경계)을 확립한다.
## Requirement
- trex-orbit 의 순수 6개 모듈(`state`/`models`/`errors`/`repository`/`service`/`in_memory_repository`)과 `__init__.py` 를 `trex_core.domain.slot` 네임스페이스로 이식하고, 모듈 간 import 경로를 `trex_core.domain.slot.*` 로 통일하며, 공개 API 를 인프라 심볼 없이 노출한다.
## Acceptance Criteria
1. Given 이식 완료, When `from trex_core.domain.slot import SlotState, Slot, SlotCapacity, SlotTransitionRecord, SlotRepository, SlotService, SlotDomainError, SlotNotFoundError, SlotStateMismatchError, SlotTransitionError, is_allowed_transition, SLOT_ADVISORY_LOCK_ID, ALLOWED_TRANSITIONS` 를 실행, Then 모두 import 에 성공한다.
2. Given 이식된 도메인 코드, When 정적 import 스윕, Then stdlib 와 `trex_core.domain.slot` 내부 참조 외 외부 의존(psycopg/네트워크/CLI/config) import 가 0이다. (NFR-1)
3. Given 공개 API, When `__init__.py` 의 `__all__` 을 확인, Then `PostgreSQLSlotRepository`·`check_slot_domain_schema_drift` 등 인프라 심볼이 노출되지 않는다.
4. Given `SlotService`, When 6 상태/8 전이/advisory-lock 직렬화/`closed→idle` 재진입 +1/lock 보유 중 외부 I/O 금지 동작을 확인, Then trex-orbit 과 동일하게 보존된다. (NFR-2 — 검증은 REQ-003)
## Impacted Area
- Docs: docs/interface-contract.md, docs/data-model.md (계약 기준 — 큰 변경 없을 수 있음)
- Modules: trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py
- Tests: REQ-003 에서 이식 단위테스트 + 계약 테스트로 검증
## Notes
- NFR-1(순수 경계)·NFR-2(불변식 보존)를 본 REQ 의 acceptance 로 흡수. NFR-3(컬럼 계약)은 REQ-003 계약 테스트가 강제한다.
- 이식 시 유일한 코드 수정은 `__init__.py` docstring 정리 수준이며 로직 변경은 없다(dcy-001 A-2).
- 네임스페이스 `trex_core.domain.slot` 은 dcy-001 OQ-1 결정. REQ-001(부트스트랩) 선행.
- 출처: dcy-001 FR-2.
## Change Log
### CHG-20260603-01
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- dcy-001 FR-2 로부터 최초 생성(Proposed). NFR-1·NFR-2 를 acceptance 로 흡수.
#### Intent
- slot 순수 도메인 이식 + 공개 API 확정 요구사항을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added:
- 공개 API import, 외부 의존 0, 인프라 심볼 비노출, 도메인 불변식 보존 4개 기준
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/interface-contract.md, docs/data-model.md
- Modules:
- trex_core/domain/slot/* (6 모듈 + __init__)
- Tests:
- REQ-003 에 위임
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 편성(REQ-001 과 동일 batch 가능)
#### Validation Plan
- 공개 API import 성공 + import 스윕(외부 의존 0) + REQ-003 테스트 통과로 검증.
### CHG-20260603-02
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register 상태 Approved)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- none (batch 편성 전)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
#### Validation Plan
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
### CHG-20260603-03
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate).
#### Intent
- BAT-001 verification.md 의 evidence(공개 API import / import 스윕 외부 의존 0 / `__all__` 인프라 심볼 0 / 도메인 불변식 테스트)로 AC1~AC4 충족 → 구현 완료 동기화.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register Implemented)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- BAT-001 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: draft-release 로 release 편성
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
@@ -0,0 +1,154 @@
# req-003: slot 도메인 단위테스트 이식 및 컬럼 계약 가드
- Status: Implemented
- Type: Testing
- Priority: High
- Owner: project maintainers
## Intent
- 이식의 정확성(도메인 불변식)과 `Slot` 컬럼 계약을 자동 테스트로 강제해, 테스트 우선 검증(Global Rule 3)을 충족하고 향후 영속화 단계의 계약 드리프트를 막는다.
## Requirement
- trex-orbit 의 순수 도메인 단위테스트 `test_state.py`·`test_service.py` 를 `tests/domain/slot/` 로 이식해 통과시키고, 도메인 패키지에 표준 필드 집합 `EXPECTED_SLOT_FIELDS` 를 선언한 뒤, 순수 계약 테스트 `test_contract.py` 가 `{f.name for f in dataclasses.fields(Slot)} == EXPECTED_SLOT_FIELDS` 를 단언하도록 신설한다.
## Acceptance Criteria
1. Given 이식한 단위테스트, When `pytest tests/domain/slot/test_state.py tests/domain/slot/test_service.py` 실행, Then 모두 통과한다. (FR-3, NFR-2 검증)
2. Given `EXPECTED_SLOT_FIELDS` 선언과 `test_contract.py`, When `pytest tests/domain/slot/test_contract.py` 실행, Then 통과한다. (NFR-3)
3. Given `Slot` 에 임시 필드를 추가하거나 삭제, When 계약 테스트를 실행, Then 테스트가 실패한다(음성 케이스 재현).
4. Given 계약 테스트, When 구현을 확인, Then 타입 단언은 하지 않고(이름/존재 중심) 타입 매핑은 dcy-001 부록 A / docs/data-model.md 문서에 둔다.
## Impacted Area
- Docs: docs/data-model.md, dcy-001 부록 A (타입 매핑 계약 기준)
- Modules: trex_core/domain/slot (EXPECTED_SLOT_FIELDS 선언; 부록 A 를 가리키는 주석 포함)
- Tests: tests/domain/slot/{test_state,test_service,test_contract}.py
## Notes
- NFR-2(불변식 검증)·NFR-3(컬럼 계약 강제)를 본 REQ 로 흡수. FR-3 + FR-5 통합.
- DB-side(information_schema) 드리프트 검사는 이번 범위 밖이며 영속화 REQ 로 인계한다(dcy-001 OQ-2).
- REQ-001(부트스트랩)·REQ-002(도메인 이식) 선행.
- 출처: dcy-001 FR-3, FR-5.
## Change Log
### CHG-20260603-01
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- dcy-001 FR-3·FR-5 로부터 최초 생성(Proposed). NFR-2 검증·NFR-3 강제를 흡수.
#### Intent
- 도메인 테스트 이식 green + 컬럼 계약 가드 요구사항을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added:
- 이식 테스트 통과, 계약 테스트 통과, 음성 케이스 실패 재현, 타입 단언 배제 4개 기준
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/data-model.md, dcy-001 부록 A
- Modules:
- trex_core/domain/slot (EXPECTED_SLOT_FIELDS)
- Tests:
- tests/domain/slot/{test_state,test_service,test_contract}.py
#### Delivery Trace Delta
- Batches:
- none
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 편성
#### Validation Plan
- `pytest tests/domain/slot` green + 필드 변경 시 계약 테스트 실패 재현으로 검증.
### CHG-20260603-02
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-001(confirmed, handoff 승인)과 모순 없음 확인.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register 상태 Approved)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- none (batch 편성 전)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
#### Validation Plan
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
### CHG-20260603-03
- Date: 2026-06-03
- Author: Justin Ko
#### Change Summary
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-001 release-candidate).
#### Intent
- BAT-001 verification.md 의 evidence(이식 테스트 green / 계약 테스트 green / rogue 필드 음성 케이스 / 타입 단언 배제)로 AC1~AC4 충족 → 구현 완료 동기화.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- docs/srs/index.md (Register Implemented)
- Modules:
- 없음
- Tests:
- 없음
#### Delivery Trace Delta
- Batches:
- BAT-001 (release-candidate)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: draft-release 로 release 편성
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
+8 -2
View File
@@ -74,14 +74,20 @@
## Next Requirement ID
- Current: `REQ-001`
- Current: `REQ-005`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
| ID | Title | Type | Status | Priority | Owner | Link |
| --- | --- | --- | --- | --- | --- | --- |
| REQ-001 | trex-core Python 프로젝트 부트스트랩 | Installation | Implemented | High | project maintainers | [req-001](./Installation/req-001_trex-core-bootstrap.md) |
| REQ-002 | slot 순수 도메인 이식 및 공개 API 확정 | Interface | Implemented | High | project maintainers | [req-002](./Interface/req-002_slot-pure-domain-port.md) |
| REQ-003 | slot 도메인 단위테스트 이식 및 컬럼 계약 가드 | Testing | Implemented | High | project maintainers | [req-003](./Testing/req-003_slot-domain-tests-and-contract-guard.md) |
| REQ-004 | baseline 문서 planned→실제 정합 | Documentation | Implemented | Medium | project maintainers | [req-004](./Documentation/req-004_baseline-doc-alignment.md) |
## Recent Change Log Summary
- 없음
- 2026-06-03 :: REQ-001~004 Approved → Implemented — BAT-001 verification evidence 기반. (confirm-req-implemented)
- 2026-06-03 :: REQ-001~004 Proposed → Approved — 승인 게이트 통과. (confirm-req)
- 2026-06-03 :: REQ-001~004 최초 생성(Proposed) — dcy-001 FR-1~FR-5 분해. (draft-req)
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "trex-core"
version = "0.1.0"
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
requires-python = ">=3.12"
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=8",
"ruff>=0.4",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["trex_core*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
View File
View File
View File
+36
View File
@@ -0,0 +1,36 @@
"""Column-contract guard for `Slot` (dcy-001 OQ-2 / REQ-003 FR-5).
Asserts the `Slot` dataclass field set matches the declared `EXPECTED_SLOT_FIELDS`
so any field add/remove fails loudly. Names/presence only — type mapping lives in
dcy-001 부록 A / docs/data-model.md, not here.
DB-side (information_schema) drift checking is out of scope for this iteration and
is deferred to the persistence REQ (dcy-001 OQ-2).
"""
from __future__ import annotations
import dataclasses
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot
def test_slot_fields_match_expected_contract():
"""AC-2: dataclass field set == declared contract."""
actual = {f.name for f in dataclasses.fields(Slot)}
assert actual == set(EXPECTED_SLOT_FIELDS)
def test_contract_detects_added_field():
"""AC-3 negative case: an extra field on a Slot-like dataclass must be caught.
Recreates the guard against a mutated copy so the test proves the assertion
fails when fields drift, without mutating the real Slot.
"""
mutated_fields = {f.name for f in dataclasses.fields(Slot)} | {"unexpected_new_field"}
assert mutated_fields != set(EXPECTED_SLOT_FIELDS)
def test_contract_detects_removed_field():
"""AC-3 negative case: a removed field must be caught."""
reduced_fields = {f.name for f in dataclasses.fields(Slot)} - {"forced_close_at"}
assert reduced_fields != set(EXPECTED_SLOT_FIELDS)
+375
View File
@@ -0,0 +1,375 @@
"""SlotService unit tests — pre-allocation, transitions, atomic counter, capacity.
Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 AC-1 ~ AC-6.
"""
from __future__ import annotations
import threading
import time
from datetime import date
from uuid import uuid4
import pytest
from trex_core.domain.slot import (
SlotNotFoundError,
SlotService,
SlotState,
SlotStateMismatchError,
SlotTransitionError,
)
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@pytest.fixture
def trading_day():
return date(2026, 5, 24)
@pytest.fixture
def repo():
return InMemorySlotRepository()
@pytest.fixture
def service(repo):
return SlotService(repo)
def _one_idle(repo, trading_day):
return repo.find_idle_slot(trading_day)
# ----------------------------------------------------------------------------
# preallocate
# ----------------------------------------------------------------------------
class TestPreallocate:
def test_creates_idle_slots(self, service, repo, trading_day):
inserted = service.preallocate_daily_slots(trading_day, 10)
assert inserted == 10
assert len(repo.list_slots(trading_day)) == 10
assert all(s.status is SlotState.IDLE for s in repo.list_slots(trading_day))
def test_idempotent_second_call_returns_zero(self, service, repo, trading_day):
"""REQ-002 AC-4: same trading_date → 0 inserted on repeat."""
service.preallocate_daily_slots(trading_day, 5)
inserted = service.preallocate_daily_slots(trading_day, 5)
assert inserted == 0
assert len(repo.list_slots(trading_day)) == 5
def test_zero_or_negative_count_rejected(self, service, trading_day):
with pytest.raises(ValueError):
service.preallocate_daily_slots(trading_day, 0)
with pytest.raises(ValueError):
service.preallocate_daily_slots(trading_day, -1)
# ----------------------------------------------------------------------------
# transit_slot — happy paths
# ----------------------------------------------------------------------------
class TestTransitHappyPath:
def test_idle_to_pending_buy_records_fields(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
updated = service.transit_slot(
slot.slot_id,
SlotState.IDLE,
SlotState.PENDING_BUY,
reason="orb_breakout",
actor="buy_decision_thread",
symbol="005930",
qty=10,
allocated_capital=1_000_000,
strategy_id="orb_v1",
)
assert updated.status is SlotState.PENDING_BUY
assert updated.symbol == "005930"
assert updated.qty == 10
assert updated.strategy_id == "orb_v1"
# log appended
log = repo.transition_log_for(slot.slot_id)
assert len(log) == 1
assert log[0].from_state is SlotState.IDLE
assert log[0].to_state is SlotState.PENDING_BUY
assert log[0].reason == "orb_breakout"
assert log[0].actor == "buy_decision_thread"
def test_pending_buy_to_holding(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="order_exec",
avg_price=70_000, entry_price=70_000,
)
cur = repo.fetch_slot(slot.slot_id)
assert cur.status is SlotState.HOLDING
assert cur.avg_price == 70_000
def test_pending_buy_to_idle_rollback_preserves_symbol_until_recovery(
self, service, repo, trading_day,
):
"""pending_buy -> idle (rejection rollback) is allowed by state machine.
Note: this rollback does NOT bump daily_symbol_entries (only closed→idle does).
"""
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
reason="broker_reject:001", actor="order_exec",
)
assert repo.get_symbol_entry_count(trading_day, "005930") == 0
# ----------------------------------------------------------------------------
# transit_slot — closed → idle atomic counter (REQ-002 AC-3)
# ----------------------------------------------------------------------------
class TestClosedToIdleRecovery:
def test_closed_to_idle_resets_fields_and_bumps_counter(
self, service, repo, trading_day,
):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
# full lifecycle: idle -> pending_buy -> holding -> pending_sell -> closed -> idle
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930", qty=10,
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="oe", avg_price=70_000, entry_price=70_000,
)
service.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="stop_loss", actor="sell",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
reason="filled", actor="oe",
)
# before recovery: count = 0
assert repo.get_symbol_entry_count(trading_day, "005930") == 0
result = service.transit_slot(
slot.slot_id, SlotState.CLOSED, SlotState.IDLE,
reason="recovery", actor="oe",
)
# fields cleared
assert result.status is SlotState.IDLE
assert result.symbol is None
assert result.qty is None
assert result.avg_price is None
assert result.allocated_capital is None
# counter incremented atomically (REQ-002 AC-3)
assert repo.get_symbol_entry_count(trading_day, "005930") == 1
# transition log retains symbol audit
log = repo.transition_log_for(slot.slot_id)
assert log[-1].from_state is SlotState.CLOSED
assert log[-1].to_state is SlotState.IDLE
def test_repeated_lifecycle_accumulates_entry_count(
self, service, repo, trading_day,
):
"""Two full lifecycles of same symbol → entry_count = 2."""
service.preallocate_daily_slots(trading_day, 2)
for _ in range(2):
slot = _one_idle(repo, trading_day)
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a", symbol="005930",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a",
)
service.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="a",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
reason="r", actor="a",
)
service.transit_slot(
slot.slot_id, SlotState.CLOSED, SlotState.IDLE,
reason="r", actor="a",
)
assert service.get_today_entry_count(trading_day, "005930") == 2
# ----------------------------------------------------------------------------
# transit_slot — rejections
# ----------------------------------------------------------------------------
class TestTransitRejections:
def test_disallowed_transition_raises(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
with pytest.raises(SlotTransitionError):
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.HOLDING,
reason="x", actor="a",
)
def test_state_mismatch_raises_when_current_state_drifted(
self, service, repo, trading_day,
):
"""Race detection — if slot already advanced, transit_slot from old state fails."""
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
with pytest.raises(SlotStateMismatchError):
# caller still thinks status=idle but it's pending_buy
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
def test_unknown_slot_raises(self, service):
with pytest.raises(SlotNotFoundError):
service.transit_slot(
uuid4(), SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
# ----------------------------------------------------------------------------
# operator error → idle/holding (REQ-004)
# ----------------------------------------------------------------------------
class TestOperatorResolve:
def test_error_to_idle_via_operator(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
# force into error
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
reason="reconcile_mismatch", actor="system",
)
result = service.transit_slot(
slot.slot_id, SlotState.ERROR, SlotState.IDLE,
reason="broker confirmed no position", actor="operator:alice",
)
assert result.status is SlotState.IDLE
log = repo.transition_log_for(slot.slot_id)
assert log[-1].actor == "operator:alice"
assert "broker confirmed" in log[-1].reason
def test_error_to_pending_buy_rejected(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
reason="r", actor="s",
)
with pytest.raises(SlotTransitionError):
service.transit_slot(
slot.slot_id, SlotState.ERROR, SlotState.PENDING_BUY,
reason="r", actor="operator:alice",
)
# ----------------------------------------------------------------------------
# compute_slot_capacity (REQ-002 AC-6)
# ----------------------------------------------------------------------------
class TestCapacity:
def test_all_idle(self, service, trading_day):
service.preallocate_daily_slots(trading_day, 10)
cap = service.compute_slot_capacity(trading_day, 10)
assert cap.idle_count == 10
assert cap.holding_count == 0
assert cap.pending_count == 0
assert cap.total_slots == 10
assert cap.scarcity_ratio == 0.0
def test_mixed_states(self, service, repo, trading_day):
service.preallocate_daily_slots(trading_day, 10)
# move 3 to pending_buy + 2 to holding
slots = repo.list_slots(trading_day)
for s in slots[:3]:
service.transit_slot(
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
for s in slots[3:5]:
service.transit_slot(
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
service.transit_slot(
s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a",
)
cap = service.compute_slot_capacity(trading_day, 10)
assert cap.idle_count == 5
assert cap.pending_count == 3
assert cap.holding_count == 2
assert cap.scarcity_ratio == pytest.approx(0.5)
def test_zero_total_rejected(self, service, trading_day):
with pytest.raises(ValueError):
service.compute_slot_capacity(trading_day, 0)
# ----------------------------------------------------------------------------
# advisory lock serialization (REQ-002 AC-5)
# ----------------------------------------------------------------------------
class TestAdvisoryLockSerialization:
def test_concurrent_transitions_are_serialized(self, service, repo, trading_day):
"""Two threads competing for the same slot — one must observe state mismatch."""
service.preallocate_daily_slots(trading_day, 1)
slot = _one_idle(repo, trading_day)
results: dict[str, Exception | None] = {}
def worker(name: str):
try:
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason=f"r-{name}", actor=name,
)
results[name] = None
except Exception as exc: # noqa: BLE001
results[name] = exc
t1 = threading.Thread(target=worker, args=("buy_thread",))
t2 = threading.Thread(target=worker, args=("sell_thread",))
t1.start()
t2.start()
t1.join()
t2.join()
# exactly one succeeded; the other observed state mismatch
successes = [k for k, v in results.items() if v is None]
failures = [k for k, v in results.items() if v is not None]
assert len(successes) == 1
assert len(failures) == 1
assert isinstance(failures[0] and results[failures[0]], SlotStateMismatchError)
def test_lock_hold_time_under_50ms(self, service, trading_day):
"""REQ-002 AC-5 numeric: transition under the in-memory repo finishes < 50 ms."""
service.preallocate_daily_slots(trading_day, 1)
slot = list(service.compute_slot_capacity.__self__._repo.list_slots(trading_day))[0] # type: ignore[attr-defined]
t0 = time.perf_counter()
service.transit_slot(
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
elapsed_ms = (time.perf_counter() - t0) * 1000
assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms"
# ----------------------------------------------------------------------------
# get_today_entry_count (REQ-001 + REQ-002 boundary)
# ----------------------------------------------------------------------------
def test_get_today_entry_count_for_unseen_symbol_is_zero(service, trading_day):
assert service.get_today_entry_count(trading_day, "999999") == 0
+96
View File
@@ -0,0 +1,96 @@
"""State machine unit tests — verify exactly the allowed transition families.
Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 / REQ-003.
"""
from __future__ import annotations
import pytest
from trex_core.domain.slot.state import (
ALLOWED_TRANSITIONS,
SLOT_ADVISORY_LOCK_ID,
SlotState,
is_allowed_transition,
)
def test_slot_state_has_six_members():
"""6 states per dcy-001 5.3.5.1."""
assert {s.value for s in SlotState} == {
"idle",
"pending_buy",
"holding",
"pending_sell",
"closed",
"error",
}
def test_advisory_lock_id_is_stable_constant():
"""SLOT_ADVISORY_LOCK_ID must not change between releases (REQ-002 NFR-1)."""
assert isinstance(SLOT_ADVISORY_LOCK_ID, int)
assert SLOT_ADVISORY_LOCK_ID == 0x5107_4D31
@pytest.mark.parametrize(
"from_state,to_state",
[
# 1-7. dcy-001 FR-2 base transitions
(SlotState.IDLE, SlotState.PENDING_BUY),
(SlotState.PENDING_BUY, SlotState.HOLDING),
(SlotState.PENDING_BUY, SlotState.IDLE),
(SlotState.HOLDING, SlotState.PENDING_SELL),
(SlotState.PENDING_SELL, SlotState.CLOSED),
(SlotState.PENDING_SELL, SlotState.HOLDING),
(SlotState.CLOSED, SlotState.IDLE),
# 8. * -> error
(SlotState.IDLE, SlotState.ERROR),
(SlotState.PENDING_BUY, SlotState.ERROR),
(SlotState.HOLDING, SlotState.ERROR),
(SlotState.PENDING_SELL, SlotState.ERROR),
(SlotState.CLOSED, SlotState.ERROR),
# operator manual resolve (REQ-004)
(SlotState.ERROR, SlotState.IDLE),
(SlotState.ERROR, SlotState.HOLDING),
],
)
def test_allowed_transition(from_state: SlotState, to_state: SlotState):
assert is_allowed_transition(from_state, to_state)
@pytest.mark.parametrize(
"from_state,to_state",
[
# explicitly disallowed: skipping intermediate states
(SlotState.IDLE, SlotState.HOLDING),
(SlotState.IDLE, SlotState.PENDING_SELL),
(SlotState.IDLE, SlotState.CLOSED),
(SlotState.HOLDING, SlotState.IDLE),
(SlotState.HOLDING, SlotState.CLOSED),
(SlotState.PENDING_BUY, SlotState.CLOSED),
(SlotState.PENDING_BUY, SlotState.PENDING_SELL),
(SlotState.PENDING_SELL, SlotState.IDLE),
(SlotState.PENDING_SELL, SlotState.PENDING_BUY),
(SlotState.CLOSED, SlotState.HOLDING),
(SlotState.CLOSED, SlotState.PENDING_BUY),
(SlotState.CLOSED, SlotState.PENDING_SELL),
# error has restricted exits — only idle and holding allowed (REQ-004)
(SlotState.ERROR, SlotState.PENDING_BUY),
(SlotState.ERROR, SlotState.PENDING_SELL),
(SlotState.ERROR, SlotState.CLOSED),
(SlotState.ERROR, SlotState.ERROR),
],
)
def test_disallowed_transition(from_state: SlotState, to_state: SlotState):
assert not is_allowed_transition(from_state, to_state)
def test_self_loops_are_not_allowed():
"""No state may transition to itself."""
for s in SlotState:
assert not is_allowed_transition(s, s)
def test_allowed_transitions_set_size():
"""7 base + 5 to-error + 2 operator-resolve = 14 transitions."""
assert len(ALLOWED_TRANSITIONS) == 14
+1
View File
@@ -0,0 +1 @@
"""trex-core — shared core for the trex product family."""
+1
View File
@@ -0,0 +1 @@
"""trex-core domain layer."""
+54
View File
@@ -0,0 +1,54 @@
"""Slot domain — daily lifecycle source of truth for trading slots.
Storage-agnostic pure domain: no broker, DB driver, CLI, or config dependency.
A repository implementation is injected via the `SlotRepository` protocol.
Public API:
- SlotState: enum of 6 slot states + allowed transitions
- Slot: data class for a slot row
- SlotCapacity: capacity snapshot returned by compute_slot_capacity
- SlotTransitionRecord: append-only transition record
- EXPECTED_SLOT_FIELDS: canonical `Slot` column contract (guarded by test_contract)
- SlotService: main domain service (preallocate, transit, capacity, entry count)
- SlotRepository: abstract storage protocol
- SlotTransitionError / SlotStateMismatchError / SlotNotFoundError: domain errors
"""
from __future__ import annotations
from trex_core.domain.slot.errors import (
SlotDomainError,
SlotNotFoundError,
SlotStateMismatchError,
SlotTransitionError,
)
from trex_core.domain.slot.models import (
EXPECTED_SLOT_FIELDS,
Slot,
SlotCapacity,
SlotTransitionRecord,
)
from trex_core.domain.slot.repository import SlotRepository
from trex_core.domain.slot.service import SlotService
from trex_core.domain.slot.state import (
ALLOWED_TRANSITIONS,
SLOT_ADVISORY_LOCK_ID,
SlotState,
is_allowed_transition,
)
__all__ = [
"ALLOWED_TRANSITIONS",
"EXPECTED_SLOT_FIELDS",
"SLOT_ADVISORY_LOCK_ID",
"Slot",
"SlotCapacity",
"SlotDomainError",
"SlotNotFoundError",
"SlotRepository",
"SlotService",
"SlotState",
"SlotStateMismatchError",
"SlotTransitionError",
"SlotTransitionRecord",
"is_allowed_transition",
]
+23
View File
@@ -0,0 +1,23 @@
"""Slot domain exceptions (REQ-002, REQ-004)."""
from __future__ import annotations
class SlotDomainError(Exception):
"""Base class for slot domain errors."""
class SlotNotFoundError(SlotDomainError):
"""Raised when a slot_id is not present in the repository."""
class SlotTransitionError(SlotDomainError):
"""Raised when a (from_state, to_state) pair is not in the allowed set."""
class SlotStateMismatchError(SlotDomainError):
"""Raised when expected from_state does not match the current slot state.
Distinct from SlotTransitionError: this fires when the slot moved to a
different state between read and write (race detection inside the
transition function).
"""
@@ -0,0 +1,113 @@
"""In-memory SlotRepository — for unit tests and current trex-core runtime.
Simulates advisory lock via threading.Lock so contention tests are still
meaningful even without a real PostgreSQL instance.
"""
from __future__ import annotations
import threading
from contextlib import contextmanager
from datetime import date, datetime, timezone
from typing import Iterator, Optional
from uuid import UUID, uuid4
from trex_core.domain.slot.errors import SlotNotFoundError
from trex_core.domain.slot.models import Slot, SlotTransitionRecord
from trex_core.domain.slot.state import SlotState
class InMemorySlotRepository:
"""Thread-safe in-memory repository for tests."""
def __init__(self) -> None:
self._slots: dict[UUID, Slot] = {}
self._transitions: list[SlotTransitionRecord] = []
self._entries: dict[tuple[date, str], tuple[int, datetime]] = {}
# Per-lock-id threading.Lock; mirrors pg_advisory_xact_lock semantics
# at process level. lock_time and contention can be observed.
self._lock_table: dict[int, threading.Lock] = {}
self._lock_table_guard = threading.Lock()
# --- lock + transaction ---
@contextmanager
def transactional_advisory_lock(self, lock_id: int) -> Iterator[None]:
lock = self._lock_for(lock_id)
lock.acquire()
try:
yield
finally:
lock.release()
def _lock_for(self, lock_id: int) -> threading.Lock:
with self._lock_table_guard:
lock = self._lock_table.get(lock_id)
if lock is None:
lock = threading.Lock()
self._lock_table[lock_id] = lock
return lock
# --- daily_slot CRUD ---
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
existing = [s for s in self._slots.values() if s.trading_date == trading_date]
if existing:
return 0
now = datetime.now(tz=timezone.utc)
for _ in range(count):
slot_id = uuid4()
self._slots[slot_id] = Slot(
slot_id=slot_id,
trading_date=trading_date,
status=SlotState.IDLE,
created_at=now,
updated_at=now,
)
return count
def list_slots(self, trading_date: date) -> list[Slot]:
return [s for s in self._slots.values() if s.trading_date == trading_date]
def fetch_slot(self, slot_id: UUID) -> Optional[Slot]:
return self._slots.get(slot_id)
def find_idle_slot(self, trading_date: date) -> Optional[Slot]:
for s in self._slots.values():
if s.trading_date == trading_date and s.status is SlotState.IDLE:
return s
return None
def update_slot(self, slot: Slot) -> None:
if slot.slot_id not in self._slots:
raise SlotNotFoundError(f"slot not found: {slot.slot_id}")
self._slots[slot.slot_id] = slot
# --- slot_transition_log (append-only) ---
def append_transition_log(self, record: SlotTransitionRecord) -> None:
# Defensive copy; reject any external mutation attempt on the list.
self._transitions.append(record)
def transition_log_for(self, slot_id: UUID) -> list[SlotTransitionRecord]:
return [t for t in self._transitions if t.slot_id == slot_id]
def all_transition_log(self) -> list[SlotTransitionRecord]:
return list(self._transitions)
# --- daily_symbol_entries ---
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
key = (trading_date, symbol)
count, _ = self._entries.get(key, (0, datetime.min))
new_count = count + 1
self._entries[key] = (new_count, datetime.now(tz=timezone.utc))
return new_count
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
count, _ = self._entries.get((trading_date, symbol), (0, datetime.min))
return count
# --- bulk counts ---
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
result: dict[SlotState, int] = {}
for s in self._slots.values():
if s.trading_date != trading_date:
continue
result[s.status] = result.get(s.status, 0) + 1
return result
+84
View File
@@ -0,0 +1,84 @@
"""Slot domain data classes (REQ-001, REQ-002)."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Optional
from uuid import UUID
from trex_core.domain.slot.state import SlotState
@dataclass
class Slot:
"""Single daily_slot row.
Matches the slot column contract recorded in dcy-001 부록 A and
docs/data-model.md (trex-core stores this in-memory for now; a future
persistence layer must satisfy the same contract).
"""
slot_id: UUID
trading_date: date
status: SlotState
symbol: Optional[str] = None
qty: Optional[float] = None
avg_price: Optional[float] = None
entry_price: Optional[float] = None
allocated_capital: Optional[float] = None
order_id: Optional[str] = None
strategy_id: Optional[str] = None
broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage)
portfolio_id: Optional[str] = None # BAT-006 / REQ-025 (trex-orbit lineage)
sell_strategy_id: Optional[str] = None # BAT-008 / REQ-047 (trex-orbit lineage)
forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
# test_contract.py asserts this matches `dataclasses.fields(Slot)` so any
# field add/remove fails loudly. Type mapping lives in the docs (dcy-001 부록 A
# / docs/data-model.md), not here — names/presence only.
EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"slot_id",
"trading_date",
"status",
"symbol",
"qty",
"avg_price",
"entry_price",
"allocated_capital",
"order_id",
"strategy_id",
"broker_order_id",
"portfolio_id",
"sell_strategy_id",
"forced_close_at",
"created_at",
"updated_at",
})
@dataclass(frozen=True)
class SlotCapacity:
"""Snapshot of slot capacity returned by compute_slot_capacity (REQ-002 AC-6)."""
idle_count: int
holding_count: int
pending_count: int
total_slots: int
scarcity_ratio: float
@dataclass(frozen=True)
class SlotTransitionRecord:
"""One append-only row in slot_transition_log (REQ-001, REQ-002)."""
slot_id: UUID
from_state: SlotState
to_state: SlotState
ts: datetime
reason: str
actor: str
extra: dict = field(default_factory=dict)
+79
View File
@@ -0,0 +1,79 @@
"""Abstract slot repository protocol (REQ-002).
Concrete implementations:
- InMemorySlotRepository — unit tests / current trex-core runtime
- (future) PostgreSQL-backed repository — added in the persistence iteration
"""
from __future__ import annotations
from contextlib import AbstractContextManager
from datetime import date
from typing import Optional, Protocol
from uuid import UUID
from trex_core.domain.slot.models import Slot, SlotTransitionRecord
from trex_core.domain.slot.state import SlotState
class SlotRepository(Protocol):
"""Storage protocol for slot domain.
Implementations MUST guarantee:
- `transactional_advisory_lock()` runs the with-block inside a single transaction
protected by an advisory lock. External I/O MUST NOT be performed inside the block.
- `append_transition_log()` is append-only (no UPDATE/DELETE).
"""
# --- lock + transaction ---
def transactional_advisory_lock(self, lock_id: int) -> AbstractContextManager[None]:
"""Return a context manager: BEGIN + pg_advisory_xact_lock + (block) + COMMIT.
On exception the transaction is rolled back and the lock released.
"""
...
# --- daily_slot CRUD ---
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
"""Insert `count` idle slots for trading_date if none exist; return inserted count.
Idempotent — if slots for trading_date already exist, returns 0 (REQ-002 AC-4).
"""
...
def list_slots(self, trading_date: date) -> list[Slot]:
"""Return all slots for the trading day."""
...
def fetch_slot(self, slot_id: UUID) -> Optional[Slot]:
"""Return single slot row or None."""
...
def find_idle_slot(self, trading_date: date) -> Optional[Slot]:
"""Return any one idle slot (None if exhausted)."""
...
def update_slot(self, slot: Slot) -> None:
"""Persist slot row (UPDATE by slot_id)."""
...
# --- slot_transition_log (append-only) ---
def append_transition_log(self, record: SlotTransitionRecord) -> None:
"""Append one row to slot_transition_log (no UPDATE/DELETE allowed)."""
...
# --- daily_symbol_entries ---
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
"""Upsert (trading_date, symbol) with entry_count += 1, last_entry_at = now.
Returns the resulting entry_count (REQ-002 AC-3).
"""
...
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
"""Return today's entry_count for symbol, or 0 if no row."""
...
# --- bulk counts for capacity ---
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
"""Return dict mapping SlotState -> count for trading_date."""
...
+250
View File
@@ -0,0 +1,250 @@
"""Slot domain service — 4 main APIs (REQ-002).
- preallocate_daily_slots(trading_date, count)
- transit_slot(slot_id, from_state, to_state, reason, actor, **fields)
- get_today_entry_count(trading_date, symbol)
- compute_slot_capacity(trading_date, total_slots)
All state-mutating APIs run inside repository.transactional_advisory_lock()
to serialize Buy/Sell decision threads + operator CLI (REQ-002 AC-2).
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime, timezone
from typing import Any, Optional
from uuid import UUID
from trex_core.domain.slot.errors import (
SlotNotFoundError,
SlotStateMismatchError,
SlotTransitionError,
)
from trex_core.domain.slot.models import Slot, SlotCapacity, SlotTransitionRecord
from trex_core.domain.slot.repository import SlotRepository
from trex_core.domain.slot.state import SLOT_ADVISORY_LOCK_ID, SlotState, is_allowed_transition
class SlotService:
"""Slot lifecycle service. Race-free transitions via advisory lock + transaction."""
def __init__(self, repository: SlotRepository) -> None:
self._repo = repository
# ------------------------------------------------------------
# preallocate_daily_slots
# ------------------------------------------------------------
def preallocate_daily_slots(self, trading_date: date, count: int) -> int:
"""Insert `count` idle slots for trading_date if none exist.
Idempotent: second+ call on the same trading_date returns 0.
Returns number of slots inserted.
REQ-002 AC-4, REQ-003 AC-5 (count must be > 0).
"""
if count <= 0:
raise ValueError(f"slot count must be > 0, got {count}")
with self._repo.transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID):
return self._repo.pre_allocate_slots(trading_date, count)
# ------------------------------------------------------------
# transit_slot
# ------------------------------------------------------------
def transit_slot(
self,
slot_id: UUID,
from_state: SlotState,
to_state: SlotState,
reason: str,
actor: str,
*,
symbol: Optional[str] = None,
qty: Optional[float] = None,
avg_price: Optional[float] = None,
entry_price: Optional[float] = None,
allocated_capital: Optional[float] = None,
order_id: Optional[str] = None,
strategy_id: Optional[str] = None,
broker_order_id: Optional[str] = None,
portfolio_id: Optional[str] = None,
sell_strategy_id: Optional[str] = None,
forced_close_at: Optional[datetime] = None,
extra: Optional[dict[str, Any]] = None,
) -> Slot:
"""Transition slot from one state to another atomically.
Within a single advisory-locked transaction:
1. fetch slot
2. verify current state == from_state (race detection)
3. verify (from_state, to_state) is in the allowed set
4. update slot row (fields per to_state semantics)
5. append transition_log row
6. on closed->idle, increment daily_symbol_entries (REQ-002 AC-3)
External I/O (broker, kis-data-hub, portfolio-hub) MUST NOT be performed
inside this function — caller does I/O outside the lock (REQ-002 AC-5).
"""
if not is_allowed_transition(from_state, to_state):
raise SlotTransitionError(
f"transition not allowed: {from_state.value} -> {to_state.value}"
)
with self._repo.transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID):
slot = self._repo.fetch_slot(slot_id)
if slot is None:
raise SlotNotFoundError(f"slot not found: {slot_id}")
# Race detection — current state must match caller's from_state.
if slot.status is not from_state:
raise SlotStateMismatchError(
f"slot {slot_id} current state {slot.status.value} "
f"!= requested from_state {from_state.value}"
)
updated = self._apply_transition_fields(
slot,
to_state,
symbol=symbol,
qty=qty,
avg_price=avg_price,
entry_price=entry_price,
allocated_capital=allocated_capital,
order_id=order_id,
strategy_id=strategy_id,
broker_order_id=broker_order_id,
portfolio_id=portfolio_id,
sell_strategy_id=sell_strategy_id,
forced_close_at=forced_close_at,
)
self._repo.update_slot(updated)
# closed -> idle recovery: bump daily_symbol_entries atomically
# (REQ-002 AC-3, REQ-001 split-by-purpose).
if from_state is SlotState.CLOSED and to_state is SlotState.IDLE:
# Use the symbol carried by the slot before reset.
symbol_for_entry = slot.symbol
if symbol_for_entry:
self._repo.increment_symbol_entry(slot.trading_date, symbol_for_entry)
self._repo.append_transition_log(
SlotTransitionRecord(
slot_id=slot_id,
from_state=from_state,
to_state=to_state,
ts=datetime.now(tz=timezone.utc),
reason=reason,
actor=actor,
extra=extra or {},
)
)
return updated
@staticmethod
def _apply_transition_fields(
slot: Slot,
to_state: SlotState,
*,
symbol: Optional[str],
qty: Optional[float],
avg_price: Optional[float],
entry_price: Optional[float],
allocated_capital: Optional[float],
order_id: Optional[str],
strategy_id: Optional[str],
broker_order_id: Optional[str] = None,
portfolio_id: Optional[str] = None,
sell_strategy_id: Optional[str] = None,
forced_close_at: Optional[datetime] = None,
) -> Slot:
"""Compute the post-transition Slot row.
Special: closed -> idle resets occupancy fields (FR-2 #7).
Special: pending_buy -> idle resets occupancy (REQ-011 AC-6 rollback).
"""
if to_state is SlotState.IDLE and slot.status in {
SlotState.CLOSED,
SlotState.PENDING_BUY,
}:
# Recovery / rollback: clear occupancy + broker handle.
# transition_log retains audit trail.
return replace(
slot,
status=SlotState.IDLE,
symbol=None,
qty=None,
avg_price=None,
entry_price=None,
allocated_capital=None,
order_id=None,
strategy_id=None,
broker_order_id=None,
portfolio_id=None,
sell_strategy_id=None,
forced_close_at=None,
updated_at=datetime.now(tz=timezone.utc),
)
# Generic update: caller-supplied fields override; None means "keep".
return replace(
slot,
status=to_state,
symbol=symbol if symbol is not None else slot.symbol,
qty=qty if qty is not None else slot.qty,
avg_price=avg_price if avg_price is not None else slot.avg_price,
entry_price=entry_price if entry_price is not None else slot.entry_price,
allocated_capital=(
allocated_capital if allocated_capital is not None else slot.allocated_capital
),
order_id=order_id if order_id is not None else slot.order_id,
strategy_id=strategy_id if strategy_id is not None else slot.strategy_id,
broker_order_id=(
broker_order_id if broker_order_id is not None else slot.broker_order_id
),
portfolio_id=(
portfolio_id if portfolio_id is not None else slot.portfolio_id
),
sell_strategy_id=(
sell_strategy_id
if sell_strategy_id is not None
else slot.sell_strategy_id
),
forced_close_at=(
forced_close_at if forced_close_at is not None else slot.forced_close_at
),
updated_at=datetime.now(tz=timezone.utc),
)
# ------------------------------------------------------------
# get_today_entry_count
# ------------------------------------------------------------
def get_today_entry_count(self, trading_date: date, symbol: str) -> int:
"""Return today's entry_count for a symbol (0 if no entries yet).
Used by buy decision state gate to enforce re-entry policy.
Reads daily_symbol_entries (decision input), NEVER queries
slot_transition_log (audit only). REQ-001, dcy-001 5.3.5.5.
"""
return self._repo.get_symbol_entry_count(trading_date, symbol)
# ------------------------------------------------------------
# compute_slot_capacity
# ------------------------------------------------------------
def compute_slot_capacity(self, trading_date: date, total_slots: int) -> SlotCapacity:
"""Return idle/holding/pending counts + scarcity_ratio (REQ-002 AC-6).
scarcity_ratio = 1 - idle_count / total_slots, clamped to [0.0, 1.0].
"""
if total_slots <= 0:
raise ValueError(f"total_slots must be > 0, got {total_slots}")
counts = self._repo.count_slots_by_status(trading_date)
idle = counts.get(SlotState.IDLE, 0)
holding = counts.get(SlotState.HOLDING, 0)
pending = counts.get(SlotState.PENDING_BUY, 0) + counts.get(SlotState.PENDING_SELL, 0)
scarcity = max(0.0, min(1.0, 1.0 - (idle / total_slots)))
return SlotCapacity(
idle_count=idle,
holding_count=holding,
pending_count=pending,
total_slots=total_slots,
scarcity_ratio=scarcity,
)
+64
View File
@@ -0,0 +1,64 @@
"""Slot state machine: 6 states + 8 allowed transitions (REQ-001, REQ-002).
The transitions match dcy-001 FR-2 and BAT-001 design.
"""
from __future__ import annotations
from enum import Enum
# Shared PostgreSQL advisory lock id for Buy/Sell decision threads and
# operator CLI manual transitions. Single constant so all callers serialize
# through the same lock (REQ-002 NFR-1, dcy-001 R-1).
SLOT_ADVISORY_LOCK_ID: int = 0x5107_4D31 # arbitrary mnemonic constant
class SlotState(str, Enum):
"""6-state slot lifecycle (dcy-001 5.3.5.1)."""
IDLE = "idle"
PENDING_BUY = "pending_buy"
HOLDING = "holding"
PENDING_SELL = "pending_sell"
CLOSED = "closed"
ERROR = "error"
# 8 allowed transitions per dcy-001 FR-2. Any pair not in this set must be
# rejected by SlotService.
# Note: `* -> error` is represented as transitions from every non-error state.
_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
(SlotState.IDLE, SlotState.PENDING_BUY), # 1. buy decision
(SlotState.PENDING_BUY, SlotState.HOLDING), # 2. buy filled
(SlotState.PENDING_BUY, SlotState.IDLE), # 3. buy rejected/rollback
(SlotState.HOLDING, SlotState.PENDING_SELL), # 4. sell decision
(SlotState.PENDING_SELL, SlotState.CLOSED), # 5. sell filled
(SlotState.PENDING_SELL, SlotState.HOLDING), # 6. sell rejected/rollback
(SlotState.CLOSED, SlotState.IDLE), # 7. recovery (with daily_symbol_entries +1)
}
)
# 8th transition family: any non-error state may move to error (operator manual
# resolve uses `error -> idle | holding` which is also covered explicitly).
_TO_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
(s, SlotState.ERROR) for s in SlotState if s is not SlotState.ERROR
)
# Operator manual resolve transitions out of error (REQ-004).
_FROM_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
(SlotState.ERROR, SlotState.IDLE),
(SlotState.ERROR, SlotState.HOLDING),
}
)
ALLOWED_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = (
_BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS
)
def is_allowed_transition(from_state: SlotState, to_state: SlotState) -> bool:
"""Return True iff (from_state, to_state) is one of the 8 transition families."""
return (from_state, to_state) in ALLOWED_TRANSITIONS