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:
@@ -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 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
|
- 문서 ID: dcy-001_20260603_slot-pure-domain-migration
|
||||||
- 이슈명: trex-core 베이스 수립 및 slot 순수 도메인 이식
|
- 이슈명: trex-core 베이스 수립 및 slot 순수 도메인 이식
|
||||||
- 작성 시각(KST): 2026-06-03
|
- 작성 시각(KST): 2026-06-03
|
||||||
- 마지막 갱신 시각(KST): 2026-06-03
|
- 마지막 갱신 시각(KST): 2026-06-03 (draft-req)
|
||||||
- 대체됨: 없음
|
- 대체됨: 없음
|
||||||
- 후속 Discovery 참조: 없음
|
- 후속 Discovery 참조: 없음
|
||||||
- 생성된 REQ 참조: 없음
|
- 생성된 REQ 참조: REQ-001, REQ-002, REQ-003, REQ-004
|
||||||
|
|
||||||
# 1. 계획 상태 요약
|
# 1. 계획 상태 요약
|
||||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||||
|
|||||||
+22
-13
@@ -55,28 +55,36 @@
|
|||||||
│ ├── project-structure.md
|
│ ├── project-structure.md
|
||||||
│ └── runtime-flows.md
|
│ └── runtime-flows.md
|
||||||
├── CLAUDE.md # SDLC governance instructions
|
├── CLAUDE.md # SDLC governance instructions
|
||||||
└── (planned)
|
├── pyproject.toml # 패키지 메타(배포명 trex-core) + pytest/ruff 설정
|
||||||
├── pyproject.toml # 패키지 메타 + pytest/ruff 설정
|
|
||||||
├── trex_core/
|
├── trex_core/
|
||||||
|
│ ├── __init__.py
|
||||||
│ └── domain/
|
│ └── domain/
|
||||||
│ └── slot/ # 이식 대상 순수 도메인
|
│ ├── __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/
|
└── tests/
|
||||||
└── domain/slot/ # 도메인 단위 테스트
|
└── 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
|
## Package / Module Responsibilities
|
||||||
|
|
||||||
- `trex_core/domain/slot/` (planned)
|
- `trex_core/domain/slot/`
|
||||||
- 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델(`models.py`), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), 테스트용 in-memory 구현(`in_memory_repository.py`).
|
- 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`).
|
||||||
- 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용.
|
- 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨).
|
||||||
- `tests/domain/slot/` (planned)
|
- `tests/domain/slot/`
|
||||||
- 상태머신/서비스 단위 테스트. InMemorySlotRepository 기반으로 실제 DB 없이 검증.
|
- 상태머신/서비스 단위 테스트(`test_state`, `test_service`) + 컬럼 계약 가드(`test_contract`). InMemorySlotRepository 기반으로 실제 DB 없이 검증.
|
||||||
- `docs/`
|
- `docs/`
|
||||||
- SDLC baseline(구조/흐름/인터페이스/데이터 모델) 및 active index(discovery/srs/batches/releases).
|
- SDLC baseline(구조/흐름/인터페이스/데이터 모델) 및 active index(discovery/srs/batches/releases).
|
||||||
- `pyproject.toml` (planned)
|
- `pyproject.toml`
|
||||||
- 패키지 메타데이터, 의존성, pytest/ruff 설정.
|
- 패키지 메타데이터(배포명 `trex-core`, `requires-python >=3.12`), dev 의존성(pytest/ruff), pytest/ruff 설정.
|
||||||
|
|
||||||
## Dependency Rules
|
## Dependency Rules
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@
|
|||||||
- 영속화 구현체(PostgreSQL repository)와 SQL 스키마/마이그레이션은 이번 범위 밖 — 향후 도입.
|
- 영속화 구현체(PostgreSQL repository)와 SQL 스키마/마이그레이션은 이번 범위 밖 — 향후 도입.
|
||||||
- CLI / 설정(config) 표면 없음(trex-orbit 의 `trex slot` CLI 미이식).
|
- CLI / 설정(config) 표면 없음(trex-orbit 의 `trex slot` CLI 미이식).
|
||||||
- 패키지 네임스페이스는 `trex_core.domain.slot` 으로 확정(dcy-001 OQ-1, 배포명 `trex-core`).
|
- 패키지 네임스페이스는 `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
|
## Update Triggers
|
||||||
|
|
||||||
@@ -106,3 +114,4 @@
|
|||||||
|
|
||||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 구조 baseline 생성(mixed: trex-orbit 코드 관찰 + 이관 계획 선언).
|
- 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 :: 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
|
## Change Log
|
||||||
|
|
||||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준).
|
- 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
|
## Next Requirement ID
|
||||||
|
|
||||||
- Current: `REQ-001`
|
- Current: `REQ-005`
|
||||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||||
|
|
||||||
## Requirement Register
|
## Requirement Register
|
||||||
|
|
||||||
| ID | Title | Type | Status | Priority | Owner | Link |
|
| 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
|
## 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