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:
+5
-1
@@ -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 는 후속 영속화 반복 인계 사항으로 비차단)
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""trex-core — shared core for the trex product family."""
|
||||
@@ -0,0 +1 @@
|
||||
"""trex-core domain layer."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user