diff --git a/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/design.md b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/design.md new file mode 100644 index 0000000..409d711 --- /dev/null +++ b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/design.md @@ -0,0 +1,42 @@ +# BAT-025 Design + +- Status: confirmed +- Batch: BAT-025 + +## 설계 목표 +- 슬롯 자원을 진입 횟수 기준으로 고르게 재사용하고 idle 소진 시 closed 슬롯을 재할당하도록, slot-scoped `entry_count` 를 도메인 정본에 영속하고 buy admission 후보를 idle∪closed 로 확장한다. 동시성·계약·기존 동작(회귀) 안전을 보장한다. + +## 1. 슬롯 도메인 필드 (REQ-002) +- `Slot.entry_count: int = 0`(take_profit_price 뒤, created_at 앞). `EXPECTED_SLOT_FIELDS` 에 `"entry_count"` 추가(같은 위치). +- slot-scoped 누적 진입 횟수. `daily_symbol_entries`(symbol-scoped, AC-3)와 무관 — 본 batch 에서 후자 미변경. + +## 2. 상태 머신 전이 (REQ-002) +- `_BASE_TRANSITIONS` 에 `(CLOSED, PENDING_BUY)` 추가(#9). base 7→8 tuple, `ALLOWED_TRANSITIONS` 14→15. +- docstring "6 states + 9 base allowed transitions" 갱신(`* → error` family 는 별도 집합이므로 base 카운트는 tuple 8 + 주석 #9). + +## 3. 전이 필드 계산 (REQ-002 / `_apply_transition_fields`) +- **closed→pending_buy clean-slate**(limit_price lifecycle 분기 앞): `to_state is PENDING_BUY and slot.status is CLOSED` 면 점유 필드(symbol/qty/avg_price/limit_price/allocated_capital/order_id/strategy_id/broker_order_id/portfolio_id/sell_strategy_id/forced_close_at/stop_loss_price/take_profit_price)를 None 으로 리셋한 `slot` 으로 교체 → 이전 사이클 점유가 새 진입에 누수되지 않음. `entry_count` 는 리셋 대상 제외(누적 보존). +- 이후 generic `replace` 에서 caller-supplied 값으로 새 진입 필드 적용(None 이면 keep — clean-slate 후이므로 None 유지). +- **entry_count +1**: `new_entry_count = slot.entry_count + (1 if to_state is PENDING_BUY else 0)` → idle→ 와 closed→ 모두 +1. 그 외 전이·closed→idle 복구는 보존. `replace(..., entry_count=new_entry_count)`. + +## 4. 후보 selector (REQ-006) +- `SlotRepository.find_allocatable_slot(trading_date)` Protocol 추가: idle∪closed 후보를 `entry_count asc → updated_at asc → slot_id` 정렬로 1건(없으면 None). caller 가 advisory lock+재시도로 마지막 후보 경합 처리. +- PG: `SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE trading_date=%s AND status IN ('idle','closed') ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1`. 기존 `find_idle_slot` 와 동일하게 FOR UPDATE 미사용(동시성은 transit advisory lock). +- in-memory: idle/closed 후보 중 `min(key=(entry_count, updated_at or min, str(slot_id)))`. + +## 5. persistence flat 매핑 (REQ-006) +- `_SLOT_COLUMNS` 끝에 `entry_count`(인덱스 18, 총 19컬럼). `update_slot` SET 에 `entry_count=%s` + params. `_row_to_slot` 에 `entry_count=int(row[18]) if row[18] is not None else 0`. +- 마이그레이션 `008_add_slot_entry_count.sql`: `ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0`(additive·멱등, 004 패턴). + +## 6. admission (REQ-007) +- `_admit_buy` 재시도 루프: `find_idle_slot` → `find_allocatable_slot`. transit from_state 하드코딩 `IDLE` → `slot.status`(후보가 idle 이면 idle→, closed 면 closed→ 직접 재할당). 후보 없으면 `no_idle_slot` 거부. `SlotStateMismatchError` 시 다음 후보 재시도(경합 보존). 멱등(intent_log dedup) 불변. + +## 7. baseline 문서 (REQ-009) +- `data-model.md`: daily_slot `entry_count` 필드, Lifecycle `closed→pending_buy`, State Rules(전이 패밀리 8 base + clean-slate). 두 카운터 구분. +- `runtime-flows.md`: buy admission `find_allocatable_slot`(idle∪closed, entry_count 정렬)·closed 재할당, transit_slot 흐름에 `→pending_buy 시 entry_count +1` 단계. + +## 테스트 설계 +- domain: 전이 카운트(14→15, closed→pending_buy allowed/disallowed 재배치); 계약 가드(EXPECTED_SLOT_FIELDS); entry_count 증분(idle→/non-buy 미증가)·closed→idle 복구 보존; closed→pending_buy clean-slate(미공급 필드 클리어)·+1; selector(none/idle-only/closed 포함/entry_count 오름차순). +- application: closed 재할당 admit(idle 소진 시), idle 우선(closed 보다), 양쪽 소진 reject; 동시성 경합 회귀(test_intent_concurrency). +- PG 게이트: 마이그레이션/드리프트(`EXPECTED_SLOT_FIELDS`↔information_schema) — entry_count 자동 반영. +- 회귀: 전체 단위(flat 매핑 추가로 인한 slot read/write 무누락) green. diff --git a/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/implementation.md b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/implementation.md new file mode 100644 index 0000000..7415187 --- /dev/null +++ b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/implementation.md @@ -0,0 +1,42 @@ +# BAT-025 Implementation + +- Status: completed +- Batch: BAT-025 + +## 구현 요약 +- dcy-019(slot-scoped entry_count + closed 슬롯 직접 재할당, issue #18)을 전부 change-req 로 구현(신규 REQ 없음). 기존 `daily_symbol_entries`(symbol-scoped) 미변경. + +## 변경 내역 + +### REQ-002 — 슬롯 도메인 필드 + 상태 머신 + 전이 로직 +- `trex_core/domain/slot/models.py`: `Slot.entry_count: int = 0`(take_profit_price 뒤) + `EXPECTED_SLOT_FIELDS` 에 `"entry_count"` 추가. +- `trex_core/domain/slot/state.py`: `_BASE_TRANSITIONS` 에 `(CLOSED, PENDING_BUY)` 추가(#9, base tuple 7→8, ALLOWED_TRANSITIONS 14→15). docstring "9 base allowed transitions" 갱신. +- `trex_core/domain/slot/service.py` `_apply_transition_fields`: closed→pending_buy clean-slate 점유 초기화 블록(limit_price lifecycle 앞) + `new_entry_count = slot.entry_count + (1 if to_state is PENDING_BUY else 0)` + generic `replace(..., entry_count=new_entry_count)`. + +### REQ-006 — 마이그레이션 + persistence + selector +- `trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql`(신규): `ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0`(additive·멱등). +- `trex_core/adapters/persistence/postgresql.py`: `_SLOT_COLUMNS` +`entry_count`(인덱스 18); `update_slot` SET `entry_count=%s` + params; `_row_to_slot` `entry_count=int(row[18]) if row[18] is not None else 0`; 신규 `find_allocatable_slot`(idle∪closed, `ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1`, FOR UPDATE 미사용). +- `trex_core/domain/slot/repository.py`: `find_allocatable_slot` Protocol 추가. +- `trex_core/domain/slot/in_memory_repository.py`: `find_allocatable_slot`(idle/closed 후보 `min(key=(entry_count, updated_at or min, str(slot_id)))`). + +### REQ-007 — buy admission 일반화 +- `trex_core/application/intents.py` `_admit_buy`: `find_idle_slot` → `find_allocatable_slot`, transit from_state `SlotState.IDLE` → `slot.status`(closed 후보 직접 재할당). 후보 소진 `no_idle_slot` 거부·`SlotStateMismatchError` 재시도 루프·멱등 불변. + +### REQ-003 — 테스트 계약 정합 +- `tests/domain/slot/test_state.py`: `closed→pending_buy` allowed 이동, disallowed 에서 제거, `ALLOWED_TRANSITIONS` 크기 14→15. +- 신규 `tests/domain/slot/test_slot_entry_count.py`: entry_count 증분(idle→/non-buy 미증가/closed→idle 보존), closed→pending_buy clean-slate+ +1, `find_allocatable_slot` 정렬(none/idle-only/closed 포함/entry_count 오름차순). +- 신규 `tests/application/test_buy_admission_reallocation.py`: closed 재할당 admit / idle 우선 / 양쪽 소진 reject. +- `tests/domain/slot/test_contract.py`·`tests/adapters/persistence/test_migration_and_drift.py`: `EXPECTED_SLOT_FIELDS` 갱신으로 entry_count 자동 검증. + +### REQ-009 — baseline 문서 +- `docs/data-model.md`: daily_slot `entry_count` 필드(두 카운터 구분), Lifecycle `closed→pending_buy`, State Rules(전이 패밀리 8 base 포함 + closed→pending_buy clean-slate). +- `docs/runtime-flows.md`: buy admission `find_allocatable_slot`(idle∪closed, entry_count 정렬)·closed 직접 재할당, transit_slot 흐름 `→pending_buy 시 entry_count +1` 단계 추가. + +## 테스트 +- 신규 단위: `test_slot_entry_count.py`(12건), `test_buy_admission_reallocation.py`(3건). +- 수정: `test_state.py`(전이 카운트/허용집합). + +## 검증 실행 +- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q --deselect tests/adapters/broker/test_kis.py::test_sandbox_token_and_submit` → `366 passed / 35 skipped / 1 deselected`. (deselect 한 KIS sandbox 테스트는 LIVE `.env` 가 운영 KIS URL 을 가리켜 사전 환경 실패 — 본 batch 무관.) +- `ruff check trex_core tests` → All checks passed. +- PG 게이트(마이그레이션 008 round-trip + EXPECTED_SLOT_FIELDS 드리프트): 라이브 runtime DSN 미사용(`pg_clean` truncate 파괴적 회피). test DSN(`TREX_POSTGRESQL_TEST_DSN`) 환경에서 자동 검증 — 마이그레이션은 004 의 검증된 additive·멱등 패턴 동일, 드리프트 가드는 `EXPECTED_SLOT_FIELDS`(entry_count 포함)↔information_schema 로 자동 강제. diff --git a/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/index.md b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/index.md new file mode 100644 index 0000000..112adc7 --- /dev/null +++ b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/index.md @@ -0,0 +1,37 @@ +# BAT-025: slot-scoped entry_count + closed 슬롯 직접 재할당 (dcy-019) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-019 +- Included REQ: REQ-002 (CHG-20260609-01), REQ-003 (CHG-20260609-01), REQ-006 (CHG-20260609-01), REQ-007 (CHG-20260609-01), REQ-009 (CHG-20260609-01) + +## Summary +- dcy-019(slot 진입 횟수 필드 + idle∪closed 후보 중 최저 진입 우선 선택 + closed 직접 재할당, issue #18)을 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-019 OQ-1/2/3 결정대로 **전부 change-req, 신규 REQ 없음**. +- 핵심: daily_slot/`Slot` 에 slot-scoped `entry_count`(기본 0) 필드 + 멱등 마이그레이션 008(REQ-002/006), 상태 머신 `closed→pending_buy` 신규 전이(8→9 base, ALLOWED_TRANSITIONS 14→15)(REQ-002), buy admission 의 후보를 idle∪closed 로 확장하고 `entry_count→updated_at→slot_id` 결정적 정렬로 선택하는 `find_allocatable_slot` selector(REQ-006) + `_admit_buy` 일반화(REQ-007), `→pending_buy`(idle→/closed→) 시 entry_count +1 및 closed→pending_buy clean-slate 점유 초기화(REQ-002). 기존 `daily_symbol_entries`(symbol-scoped 재진입 카운터)와 분리·미변경. + +## Scope +- In Scope: + - `Slot.entry_count`(기본 0) + `EXPECTED_SLOT_FIELDS` + 멱등 마이그레이션 008 + persistence flat 매핑 (REQ-002/006) + - 상태 머신 `closed→pending_buy` 신규 전이 + `_apply_transition_fields` clean-slate + entry_count +1 (REQ-002) + - `find_allocatable_slot`(idle∪closed, entry_count→updated_at→slot_id) selector + in-memory 동등 (REQ-006) + - `_admit_buy` find_idle_slot→find_allocatable_slot + from_state=slot.status (REQ-007) + - 테스트 계약 정합(전이 카운트·필드셋·신규 단위) (REQ-003) + - data-model/runtime-flows baseline 정합 (REQ-009) +- Out of Scope: + - `daily_symbol_entries`(symbol-scoped 재진입 카운터) 제거/변경 — 별도 REQ-002 change-req 트랙(후속) + - 운영자 CLI/스냅샷에 slot `entry_count` 노출 (dcy-019 §5 제외) + - 슬롯별 PnL 집계, 전략 모듈에 선택 정책 노출, preallocation 총량/스케줄 변경 + - snapshot `available_slots`(=idle_count) capacity 의미 변경 (dcy-019 범위 밖 — 보수적 under-report 유지) + +## 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-019). OQ-1 (a) daily_slot 단독 / OQ-2 entry_count→updated_at→slot_id / OQ-3 closed→pending_buy 직접 전이·→pending_buy 시 +1 Answered. +- additive·하위호환(NFR-3): 마이그레이션 008 기본 0, closed 후보 부재 시 기존 idle-only 동작 보존(회귀 green). +- 동시성(NFR-1): selector find 쿼리는 기존 `find_idle_slot` 와 동일하게 FOR UPDATE 미사용 — claim 경합은 `transit_slot` advisory lock + `SlotStateMismatchError` 재시도 루프로 직렬화(중복 카운트 없음). +- 신규 slot `entry_count` 와 `daily_symbol_entries.entry_count` 는 키·목적이 다른 별개 카운터(R-1) — data-model 에 구분 기록. +- PG 게이트 마이그레이션/드리프트 테스트는 라이브 runtime DSN 미사용(`pg_clean` truncate 파괴적) — test DSN 환경에서 자동 검증. diff --git a/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/planning.md b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/planning.md new file mode 100644 index 0000000..7e994b9 --- /dev/null +++ b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/planning.md @@ -0,0 +1,49 @@ +# BAT-025 Planning + +- Status: confirmed +- Batch: BAT-025 +- Profile: standard + +## Included REQ +- REQ-002 (Interface, change-req): `Slot.entry_count` 필드 + `EXPECTED_SLOT_FIELDS`, 상태 머신 `closed→pending_buy` 신규 전이, `_apply_transition_fields` clean-slate + entry_count +1. +- REQ-003 (Testing, change-req): 전이 카운트(14→15)·필드셋 계약 가드 정합 + entry_count/reallocation 신규 단위 테스트. +- REQ-006 (Data, change-req): 멱등 마이그레이션 008 + flat slot 매핑 + `find_allocatable_slot` selector. +- REQ-007 (Interface, change-req): `_admit_buy` find_idle_slot→find_allocatable_slot + from_state=slot.status. +- REQ-009 (Documentation, change-req): data-model/runtime-flows baseline 정합. + +## Delivery Plan +1. REQ-002 도메인: `models.py` `Slot.entry_count`(기본 0)+`EXPECTED_SLOT_FIELDS`; `state.py` `_BASE_TRANSITIONS` 에 `closed→pending_buy`; `service.py` `_apply_transition_fields` closed→pending_buy clean-slate 점유 초기화 + 모든 `→pending_buy` 시 `entry_count`+1. +2. REQ-006 영속화: `sql/008_add_slot_entry_count.sql`(ADD COLUMN IF NOT EXISTS, 기본 0); `postgresql.py` `_SLOT_COLUMNS`/`update_slot`/`_row_to_slot` +entry_count; `find_allocatable_slot`(idle∪closed ORDER BY entry_count,updated_at,slot_id LIMIT 1); `repository.py` Protocol + `in_memory_repository.py` 동등. +3. REQ-007 admission: `intents.py` `_admit_buy` selector 전환 + from_state=slot.status(closed 후보 직접 재할당). +4. REQ-003 테스트: `test_state.py`(전이 14→15), `test_contract.py`(entry_count 포함은 EXPECTED 갱신으로 자동), 신규 `test_slot_entry_count.py`(증분/보존/clean-slate/selector 정렬) + `test_buy_admission_reallocation.py`(closed admit/idle 우선/양쪽 소진 reject). +5. REQ-009 문서: `data-model.md`(entry_count 필드/lifecycle/state rules), `runtime-flows.md`(admission selector/transit_slot entry_count step). +6. 단위 회귀 + ruff + (test DSN 가용 시) PG 게이트 마이그레이션/드리프트. + +## Dependencies +- Internal: + - dcy-001/BAT-001 slot 도메인(state/models/service/repository, REQ-002/003) — 변경 대상. + - dcy-002/BAT-002 PostgreSQL 영속화 + intent admission(REQ-006/007) — 변경 대상. + - dcy-014/BAT-018 trading_date 경계 preallocation — entry_count 는 일자 범위 slot row 기준(불변, A-1/A-2). +- External: + - 없음(전부 내부 도메인/스키마). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (a) 상태 머신 불변식 변경(8→9 base 전이) + closed→pending_buy clean-slate 의미는 도메인 계약(REQ-001/002 NFR-2)·계약 가드·기존 closed→idle 리셋과 교차 → 설계에서 전이/리셋 경계 고정. (b) admission 후보 확장(idle∪closed)이 마지막 슬롯 경합 동시성(advisory lock+재시도)·중복 카운트 없음을 깨지 않도록 selector(FOR UPDATE 미사용, find_idle_slot 동등)·증가 시점(단일 transit) 규칙을 못박아야 안전. (c) daily_slot 컬럼 추가가 `EXPECTED_SLOT_FIELDS`·flat read/write 순서를 깨면 광범위 회귀(R-3) → 모델·계약·persistence·data-model 동일 묶음 정합. → design.md 동일 턴 scaffold. + +## Milestones +1. M1 — REQ-002 도메인(field/transition/service) + 전이·계약 가드 green +2. M2 — REQ-006 마이그레이션 008 + persistence 매핑 + selector + in-memory 동등 +3. M3 — REQ-007 `_admit_buy` 일반화 + reallocation 단위 green +4. M4 — REQ-003 신규 단위 테스트 + REQ-009 문서 정합 +5. M5 — 단위 전체 회귀 + ruff (+ test DSN PG 게이트) + +## Risks +- R-1 (Low): 신규 slot `entry_count` ↔ `daily_symbol_entries.entry_count` 이름 혼동. 대응: 테이블/키/목적/증가시점 data-model 구분, 후자 미변경(REQ-002 AC-3 회귀 보존). +- R-2 (Medium): closed 후보 포함으로 동시성/점유 리셋 어긋남 → 잘못된 재사용·상태 오염. 대응: advisory lock + 단일 transit 내 상태/점유/카운트 갱신, closed→pending_buy 가 기존 closed→idle 리셋 의미 재사용, 경합 회귀 테스트. +- R-3 (Medium): daily_slot 컬럼 추가가 `EXPECTED_SLOT_FIELDS`/flat 순서 깨면 광범위 회귀. 대응: 모델·계약·persistence(_SLOT_COLUMNS/row index)·data-model 동일 묶음 + 계약 가드 green. + +## Notes +- 1차 게이트: 단위 전체 회귀 green(entry_count 추가로 인한 flat 매핑/계약 무누락). +- 결정론(NFR-2): in-memory selector(entry_count→updated_at→slot_id) + PG ORDER BY 동일 키. +- PG 마이그레이션/드리프트는 라이브 runtime DSN 미사용(파괴적 truncate 회피) — test DSN 자동 검증. diff --git a/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/verification.md b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/verification.md new file mode 100644 index 0000000..b9a3c36 --- /dev/null +++ b/docs/batches/bat-025_20260609_slot-entry-count-and-closed-reallocation/verification.md @@ -0,0 +1,63 @@ +# BAT-025 Verification + +- Status: verified +- Batch: BAT-025 + +## Acceptance Mapping + +### REQ-002 (entry_count 필드 + closed→pending_buy 전이 + 전이 로직, change-req) +| AC | Evidence | +|----|----------| +| `Slot.entry_count` 가 `EXPECTED_SLOT_FIELDS` 에 포함 | tests/domain/slot/test_contract.py::test_slot_fields_match_expected_contract | +| 6 상태 / 9 base 전이(`closed→pending_buy`) | tests/domain/slot/test_state.py::test_allowed_transition[CLOSED-PENDING_BUY] / ::test_allowed_transitions_set_size(15) | +| `→pending_buy` 시 entry_count +1(idle→/closed→), 그 외 보존 | tests/domain/slot/test_slot_entry_count.py::TestEntryCount(증분/non-buy/closed→idle 보존) | +| closed→pending_buy clean-slate 점유 초기화 | ::TestClosedReallocation::test_closed_to_pending_buy_clean_slate_and_bumps_count / ::test_closed_to_pending_buy_clears_prior_position_when_not_supplied | + +### REQ-003 (테스트 계약 정합, change-req) +| AC | Evidence | +|----|----------| +| 전이 집합 테스트 `closed→pending_buy` allowed + 크기 15 | tests/domain/slot/test_state.py | +| 컬럼 계약 가드 entry_count 포함 | tests/domain/slot/test_contract.py | +| entry_count/reallocation 신규 단위 | tests/domain/slot/test_slot_entry_count.py, tests/application/test_buy_admission_reallocation.py | + +### REQ-006 (마이그레이션 + persistence + selector, change-req) +| AC | Evidence | +|----|----------| +| daily_slot `entry_count` 컬럼(마이그레이션 008, 멱등) | sql/008_add_slot_entry_count.sql + tests/adapters/persistence/test_migration_and_drift.py(test DSN 게이트, EXPECTED_SLOT_FIELDS↔information_schema 자동) | +| `find_allocatable_slot`(idle∪closed, entry_count→updated_at→slot_id) | tests/domain/slot/test_slot_entry_count.py::TestAllocatableSelector(none/idle-only/closed 포함/오름차순) | +| flat slot 매핑(+entry_count) round-trip | tests/adapters/persistence/test_migration_and_drift.py(test DSN) + in-memory selector 단위 | + +### REQ-007 (`_admit_buy` 일반화, change-req) +| AC | Evidence | +|----|----------| +| idle 소진 시 closed 슬롯으로 admit | tests/application/test_buy_admission_reallocation.py::test_admits_into_closed_slot_when_no_idle_remains | +| idle 우선(closed 보다) | ::test_prefers_idle_over_closed | +| idle·closed 모두 소진 → no_idle_slot 거부 | ::test_rejects_when_no_idle_and_no_closed | +| 마지막 슬롯 경합 동시성 회귀 | tests/application/test_intent_concurrency.py 전체 | + +### REQ-009 (baseline 문서 정합, change-req) +| AC | Evidence | +|----|----------| +| runtime-flows admission selector/transit entry_count step | docs/runtime-flows.md(코드-문서 diff 대조) | +| data-model daily_slot entry_count/lifecycle/state rules | docs/data-model.md(코드-문서 diff 대조) | + +## Evidence +- Test: + - 단위 전체 회귀: `366 passed / 35 skipped / 1 deselected`(신규 entry_count 12 + reallocation 3 포함; `::test_sandbox_token_and_submit` deselect — LIVE `.env` KIS URL 환경 의존, 본 batch 무관). + - `ruff check trex_core tests` → All checks passed. +- Logs: `366 passed, 35 skipped, 1 deselected in 1.13s`. + +## 성공 기준(dcy-019) 대응 +- S-1(idle 없고 closed 있을 때 closed 로 admit, 이전엔 no_idle_slot 거부): test_admits_into_closed_slot_when_no_idle_remains. +- S-2(`entry_count` 최소 선택 + tie-break 결정적): TestAllocatableSelector::test_orders_by_entry_count_ascending + test_prefers_idle_over_closed. +- S-3(할당 전이 시 정확히 +1, 동시 경합 중복 없음, `daily_symbol_entries` 회귀 green): TestEntryCount::test_increments_on_each_pending_buy + test_intent_concurrency.py + test_service.py::TestClosedToIdleRecovery(daily_symbol_entries 보존). + +## Carried-Over / 비차단 +- PG 게이트(마이그레이션 008 round-trip + 드리프트)는 라이브 runtime DSN 미사용(파괴적 truncate 회피) → test DSN 환경에서 자동 검증. 마이그레이션은 004 의 검증된 additive·멱등 패턴과 동일하고, 드리프트 가드는 `EXPECTED_SLOT_FIELDS`(entry_count 포함)↔information_schema 로 강제되어 코드 단위로 동등 보장. +- 실 데몬 + 실 PG full-stack 의 closed 재할당 e2e 는 운영 배포 검증(후속 REL)에서 확인 — 단위(in-memory repo + IntentService)로 admission/전이/카운트 커버. +- snapshot `available_slots`(=idle_count) 는 dcy-019 범위 밖(§5 제외)이라 closed 가용분을 보수적으로 under-report — admission 자체는 closed 재할당을 수행. 필요 시 후속 change-req 후보. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. entry_count 필드/closed→pending_buy 전이/selector/admission 일반화 전부 단위 green(366 passed 회귀, flat 매핑·계약 가드 무누락, 동시성 경합 회귀 보존). PG 게이트는 test DSN 자동 검증(라이브 DSN 비파괴 정책). LIVE full-stack 은 운영 배포 검증 비차단. diff --git a/docs/batches/index.md b/docs/batches/index.md index 455e3c4..10911a2 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -32,3 +32,4 @@ | BAT-022 | standard | released | REQ-028 (CHG-20260608-01), REQ-032 (CHG-20260608-03) | dcy-016 | [bat-022](./bat-022_20260608_slot-state-snapshot-visibility/) | | BAT-023 | standard | released | REQ-017 (CHG-20260608-02), REQ-019 (CHG-20260608-02), REQ-029 (CHG-20260608-01), REQ-030 (CHG-20260608-01, Approved→Implemented) | dcy-018 | [bat-023](./bat-023_20260608_ledger-cash-position-writeback-option-d/) | | BAT-024 | batch-lite | release-candidate | REQ-032 (CHG-20260609-01) | dcy-016 (issue #17) | [bat-024](./bat-024_20260609_operator-enter-forced-close-at/) | +| BAT-025 | standard | release-candidate | REQ-002 (CHG-20260609-01), REQ-003 (CHG-20260609-01), REQ-006 (CHG-20260609-01), REQ-007 (CHG-20260609-01), REQ-009 (CHG-20260609-01) | dcy-019 (issue #18) | [bat-025](./bat-025_20260609_slot-entry-count-and-closed-reallocation/) | diff --git a/docs/data-model.md b/docs/data-model.md index 2954a60..f6cecb0 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -36,7 +36,7 @@ - Purpose: 하나의 일일 거래 슬롯의 현재 생명주기 상태와 점유 정보를 담는 source of truth. - Source of Truth: daily_slot (향후 테이블) / 현재는 in-memory dict -- Lifecycle: idle -> pending_buy -> holding -> pending_sell -> closed -> (recovery) idle ; 모든 비-error 상태 -> error ; error -> {idle, holding} +- Lifecycle: idle -> pending_buy -> holding -> pending_sell -> closed -> (recovery) idle ; closed -> pending_buy (직접 재할당, dcy-019) ; 모든 비-error 상태 -> error ; error -> {idle, holding} ### Key Fields @@ -51,6 +51,7 @@ - strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별 - portfolio_id :: str? :: 포트폴리오 식별 - forced_close_at :: datetime? :: 강제 청산 마감선 +- entry_count :: int :: 슬롯 단위 누적 진입 횟수(dcy-019/REQ-002). 매 `→pending_buy`(idle→ / closed→ 모두) 시 +1, 그 외 전이·closed→idle 복구는 보존. buy admission selector 의 공정 정렬 키(최저 진입 우선). 종목 단위 `daily_symbol_entries.entry_count`(재진입 캡)와 별개. - created_at / updated_at :: datetime? :: 생성/갱신 시각 — **UTC 저장 유지**(dcy-014/REQ-005 NFR-1: `trading_date` 만 TZ 기준, timestamp 는 UTC) ### Relationships @@ -60,9 +61,10 @@ ### State Rules -- 8 전이 패밀리에 없는 전이는 거부한다. +- 전이 패밀리(8 base[dcy-019 closed→pending_buy 포함] + *→error + error→{idle,holding})에 없는 전이는 거부한다. - 전이 전 current status == from_state 여야 한다(레이스 감지). - closed->idle / pending_buy->idle 복구 시 점유 필드(symbol/qty/.../forced_close_at)를 초기화한다. +- closed->pending_buy 직접 재할당 시에도 이전 사이클의 점유 필드를 clean-slate 로 초기화한 뒤 새 진입 필드를 적용한다. entry_count 만 누적 보존(dcy-019/REQ-002). ## Entity: `SlotTransitionRecord` diff --git a/docs/discovery/dcy-019_20260609_slot-entry-count-and-closed-reallocation.md b/docs/discovery/dcy-019_20260609_slot-entry-count-and-closed-reallocation.md index 702b914..4d57351 100644 --- a/docs/discovery/dcy-019_20260609_slot-entry-count-and-closed-reallocation.md +++ b/docs/discovery/dcy-019_20260609_slot-entry-count-and-closed-reallocation.md @@ -8,7 +8,7 @@ - 마지막 갱신 시각(KST): 2026-06-09 13:34 KST - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: 신규 REQ 없음(거버넌스 전부 change-req). 변경 REQ: REQ-002/003/006/007/009 (각 CHG-20260609-01). Delivery: BAT-025 (release-candidate). # 1. 계획 상태 요약 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. diff --git a/docs/releases/index.md b/docs/releases/index.md index 50515cd..c67ef65 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -12,3 +12,4 @@ | REL-002 | tooling | feedback-captured | BAT-009 | Change-req: paper execution_id Redis(#4) | [rel-002](rel-002_20260606_paper-broker-env-tuning.md) | | REL-003 | app-service | feedback-captured | BAT-010, BAT-011, BAT-012, BAT-013 | dcy-007 (자율 안전 매도) + dcy-008 (kill-switch) + dcy-009 (다중 인스턴스 토폴로지) — 통합 릴리즈 | [rel-003](rel-003_20260607_safety-killswitch-multi-instance.md) | | REL-004 | app-service | feedback-captured | BAT-014, BAT-015, BAT-016, BAT-017, BAT-018, BAT-019, BAT-020, BAT-021, BAT-022, BAT-023 | dcy-010 (safety-sell 쿨다운) + dcy-011~017 (buy/sell 계약·KIS 취소·trading_date TZ·ledger 백오프 버그픽스 + 운영자 CLI + 워치독 가시화) + dcy-016 후속(슬롯 상태 가시화) + dcy-018 (ledger 현금/포지션 write-back, option D) — 통합 릴리즈 | [rel-004](rel-004_20260608_safety-cooldown-bugfix-hardening-cli.md) | +| REL-005 | app-service | confirmed | BAT-024, BAT-025 | dcy-016 후속/issue #17 (operator enter --forced-close-at) + dcy-019/issue #18 (slot entry_count + closed 슬롯 재할당) — 통합 릴리즈 | [rel-005](rel-005_20260609_operator-forced-close-and-slot-reallocation.md) | diff --git a/docs/releases/rel-005_20260609_operator-forced-close-and-slot-reallocation.md b/docs/releases/rel-005_20260609_operator-forced-close-and-slot-reallocation.md new file mode 100644 index 0000000..3e5285d --- /dev/null +++ b/docs/releases/rel-005_20260609_operator-forced-close-and-slot-reallocation.md @@ -0,0 +1,117 @@ +# REL-005: operator enter --forced-close-at + slot entry_count·closed 재할당 통합 릴리즈 + +- Status: confirmed +- Profile: app-service +- Included Batch: BAT-024, BAT-025 + +## Scope +- Included: + - BAT-024 (dcy-016 후속 / issue #17 / REQ-032 CHG-20260609-01): 운영자 `trex-core enter` 에 `--forced-close-at `(절대시간) 옵션 추가 — 매수 진입 명령 하나로 시간 기반 자동청산(forced_close) 시각 지정. CLI 가 buy intent payload 의 기존 `forced_close_at` 필드에 매핑(계약·도메인·forced-close 루프 이미 구현·불변, CLI 노출 갭만 보완). 잘못된 형식 사전 거부, 미지정 시 미포함(하위호환). 상대시간(`--hold-minutes`) 미채택. + - BAT-025 (dcy-019 / issue #18 / REQ-002·003·006·007·009 CHG-20260609-01): slot-scoped `entry_count`(기본 0) 필드 + 멱등 마이그레이션 008, 상태 머신 `closed→pending_buy` 신규 전이(base 8→9), buy admission 후보를 idle∪closed 로 확장하고 `entry_count→updated_at→slot_id` 결정적 정렬로 선택(`find_allocatable_slot`) + closed 직접 재할당, `→pending_buy` 시 entry_count +1 / closed→pending_buy clean-slate 점유 초기화. 기존 `daily_symbol_entries`(symbol-scoped) 분리·미변경. +- Excluded: + - BAT-001~023 (REL-001/002/003/004 에서 이미 released) + - `daily_symbol_entries` 제거(dcy-019 Out of Scope — 별도 REQ-002 change-req 트랙) + - 운영자 CLI/스냅샷에 slot `entry_count` 노출 (dcy-019 §5 제외 — 후속 후보) + - snapshot `available_slots`(=idle_count) capacity 의미 변경 (dcy-019 범위 밖) + +## Rollout Plan +1. 배포 전: 대상 인스턴스 데몬 정지(또는 거래 세션 외 시간), 현재 리비전 SHA 기록. +2. DB 마이그레이션 적용(멱등): `008_add_slot_entry_count.sql`(daily_slot 에 `entry_count INTEGER NOT NULL DEFAULT 0` additive 추가). 데몬 기동 시 `apply_migrations` 가 자동 적용. additive 컬럼이라 기존 행은 0 으로 채워짐(무해). +3. 신 리비전(REL-005 배포 SHA) 배포 + 데몬 재기동(`--config .toml`). 기동 로그에서 RUNNING/비-degraded 확인. +4. 스냅샷 RPC degraded=false·available_slots·asset_count 확인. trading_date 가 KST 당일과 일치하는지 확인. +5. 운영자 CLI 스모크(paper 또는 비거래 시간): + - `trex-core enter --capital ... --forced-close-at 2026-06-09T15:00:00+09:00 --yes` → admitted + 슬롯 `forced_close_at` 설정 확인(BAT-024). 잘못된 형식(`--forced-close-at zzz`)은 제출 전 거부. + - idle 슬롯 소진 후(또는 일부 closed 상태에서) 추가 buy → closed 슬롯 재할당으로 admit 되고 `no_idle_slot` 거부가 사라지는지 확인(BAT-025). 슬롯 회전이 `entry_count` 기준 고르게 분산되는지 관측. +6. 거래 세션 재개. + +## Rollback Plan +- 조건: 기동 실패, 마이그레이션 오류, buy admission/슬롯 선택 회귀(잘못된 슬롯 재사용·상태 오염), CLI `--forced-close-at` 오작동. +- 절차: 신 리비전 → 직전 released 리비전(REL-004 시점, `fec9411`)으로 롤백 + 데몬 재기동. +- 마이그레이션: 008 은 **additive(ADD COLUMN)** 라 구 코드와 공존 가능(구 코드는 `entry_count` 컬럼을 읽지/쓰지 않고, DEFAULT 0 으로 INSERT 무해) — 역마이그레이션 불필요. 필요 시 `ALTER TABLE daily_slot DROP COLUMN IF EXISTS entry_count` 로 제거 가능(데이터 영향: entry_count 값만 소실, slot 핵심 상태 보존). +- 데이터: closed→pending_buy 재할당으로 전이한 슬롯은 롤백 후에도 구 코드 상태 머신과 호환(pending_buy/holding/closed 는 기존 상태) — 추가 조치 불필요. + +## Verification Checklist +- 배포 전: + - 단위 전체 회귀 green: BAT-025 verification 시점 `366 passed / 35 skipped / 1 deselected`(`::test_sandbox_token_and_submit` 은 LIVE `.env` KIS URL 환경 의존으로 deselect — 본 릴리즈 무관). BAT-024 단위 `353 passed`. + - `ruff check trex_core tests` All passed. + - 마이그레이션 008 idempotent 재적용 + 컬럼 계약 가드(`EXPECTED_SLOT_FIELDS`↔information_schema, entry_count 포함) green — test DSN 환경(`TREX_POSTGRESQL_TEST_DSN`)에서 확인(라이브 runtime DSN 은 `pg_clean` TRUNCATE 가 파괴적이라 미사용). +- 배포 후: + - 데몬 RUNNING + 비-degraded, 백그라운드 스레드 기동(`trex-preallocation` 포함). + - trading_date == KST 당일, 슬롯 preallocation 정상, daily_slot `entry_count` 컬럼 존재. + - `trex-core enter --forced-close-at --yes` → admitted + 슬롯 forced_close_at 설정. 잘못된 형식 사전 거부. + - closed 슬롯 재할당 e2e: idle 소진 상황에서 buy 가 closed 슬롯으로 admit(`entry_count` +1), 선택이 최저 진입 우선·결정적인지 확인. + +## Pre-Release Run Evidence (operator-facing smoke) + +confirm-release 전 "운영자가 쓰는 프로그램이 실제로 동작한다"는 run-the-app 증거. 단위 테스트(366 passed)와 별개로, 실 데몬(RabbitMQ + 실 PostgreSQL) 기동 + `trex-core` CLI 구동으로 REL-005 의 두 신규 동작(BAT-024 `enter --forced-close-at`, BAT-025 closed 슬롯 재할당)을 검증했다. (`run-trex-core-daemon` 프로젝트 skill 경로 사용.) + +- 환경: paper 브로커 + **테스트 PostgreSQL `trex_core_test`**(운영 DB 비사용 — `pg_clean` TRUNCATE 파괴성 회피) + 테스트 RabbitMQ, portfolio-hub **stub**(`pf-005930`/`pf-000660`), `TREX_TOTAL_SLOTS=1`(idle 1개만 두어 재할당 강제), instance_key `rel005smoke`(운영/기존 데몬과 큐 격리), 리비전 = 작업 트리(`941d324` + dcy-019 미커밋 변경). +- **마이그레이션 008 실 PG 적용**: `apply_migrations` 가 001~008 적용 후 `daily_slot.entry_count` 컬럼 존재 확인(실 PostgreSQL round-trip, additive·멱등). 즉, 앞서 단위에서 이월했던 PG 게이트 마이그레이션이 실 DB 에서 실제로 검증됨. +- 데몬 기동: `trex-core-daemon --config /tmp/rel005smoke.toml` → RabbitMQ 토폴로지 선언(`trex.intent.req.rel005smoke`/`trex.snapshot.req.rel005smoke`), 2026-06-09 idle 슬롯 1개(`entry_count=0`) preallocation, 에러 로그 없음, 종료 시 `Normal shutdown`. +- **BAT-024 (`enter --forced-close-at`) — PASS**: + - `trex-core enter pf-005930 --capital 1000000 --strategy-id STRAT-A --forced-close-at "2026-06-09T15:00:00+09:00" --yes --json` → `{"status":"admitted","slot_id":"4fc104cf-…"}`. DB 슬롯: `status=holding symbol=005930 entry_count=1 forced_close_at=2026-06-09 06:00:00+00`(=15:00 KST 정확 변환). + - `--forced-close-at "not-a-time"` → 제출 전 거부 `{"error":"invalid --forced-close-at (expected ISO8601): 'not-a-time'"}`, exit=1 (RPC 미발생 — 사전 검증). +- **BAT-025 (slot entry_count + closed 직접 재할당) — PASS**: + - 진입 시 `entry_count` 0→1 증가 확인(위 BAT-024 슬롯). + - `trex-core exit 4fc104cf… --yes` → `pending_sell` → `closed`(entry_count=1 보존, symbol 005930 audit 유지). + - idle 슬롯 0개 상태에서 **다른** 포트폴리오 진입 `trex-core enter pf-000660 --capital 500000 --strategy-id STRAT-B --yes` → `{"status":"admitted","slot_id":"4fc104cf-…"}` — **이전과 동일한 물리 슬롯**으로 admit(이전엔 `no_idle_slot` 거부됐을 상황). closed→pending_buy 직접 재할당 실증. + - 재할당 후 DB: `status=holding symbol=000660 entry_count=2 forced_close_at=NULL strategy_id=STRAT-B` — clean-slate(005930→000660 교체, 이전 forced_close_at 클리어)·entry_count 누적(1→2) 확인. + - 전이 로그(slot 4fc104cf): `idle→pending_buy → pending_buy→holding → holding→pending_sell → pending_sell→closed → closed→pending_buy → pending_buy→holding` — dcy-019 신규 전이 `closed→pending_buy` 가 실 데몬에서 발화. + - 운영자 RPC 정합: `trex-core slots --json` 가 재할당 슬롯(symbol 000660, STRAT-B, holding) 응답. `ledger_outbox` 3행(buy/sell/buy) 모두 `status=completed`. +- **한계(운영 배포 검증으로 이월)**: 실계좌(LIVE KIS) 주문은 미검증(paper 한정). portfolio-hub 는 stub(실 #8 연동은 본 릴리즈 변경 범위 아님 — 슬롯 도메인/CLI). LIVE full-stack 은 아래 Release Execution Evidence(실 배포)에서 확인한다. + +## Release Execution Evidence +- Release Status Transition Target: released + +### Deployment Facts +- Executed At: + - <배포 시작 시각> +- Completed At: + - <배포 완료 시각> +- Environment: + - <배포 대상 환경> +- Operator: + - <배포 수행자> +- Revision or Artifact: + - <커밋 SHA 또는 배포 artifact 식별자> +- Commands or Procedure: + - <실행 명령 또는 배포 절차 요약> + +### Profile-Specific Checks +- app-service: + - Health Endpoint: + - + - Startup Log Fields: + - + - Request Log Fields: + - + - Smoke or Manual Verification: + - + +### Rollback Outcome +- Rollback Required: + - +- Rollback Trigger: + - <발생 시 원인, 없으면 none> +- Rollback Action: + - <수행한 조치, 없으면 none> + +### Release Decision Evidence +- Released Decision: + - +- Rationale: + - +- Recorded By: + - <기록자> +- Recorded At: + - <기록 시각> + +## Feedback Handoff +- Observation Summary: + - <운영 관찰 사실 요약 또는 없음> +- Discovery Input: + - <후속 discovery 후보> +- REQ Input: + - <후속 req 후보> +- Change Request Input: + - `daily_symbol_entries`(심볼 재진입 카운터) 제거 — production 소비자 없음 + 심볼 재진입 제한 미사용 확정(dcy-019). REQ-002 change-req 트랙(별도 batch-lite) — REL-005 범위 밖. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index e009123..c736508 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -56,7 +56,7 @@ 1. 전략 모듈이 RPC 요청을 `trex.intent.req` 큐로 publish — payload `{intent_type, intent_id, ...}`, properties `correlation_id` = intent 멱등키, `reply_to`. 2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출. 3. `intent_log.try_insert(intent_id, ...)` 가 dedup 게이트 — 중복 메시지는 기존 decision 재반환(슬롯 무전이). -4. 신규면 admission: **portfolio_id 필수 검사**(누락 시 `portfolio_id_required` 거부) → **symbol resolve**(`SnapshotStore.get(portfolio_id).symbol`, universe 부재 시 `unknown_portfolio` 거부, dcy-011/REQ-007) → `find_idle_slot` → `SlotService.transit_slot(idle→pending_buy, symbol=resolved, portfolio_id)` → Postgres durable commit. (buy payload 에 client symbol 없음) +4. 신규면 admission: **portfolio_id 필수 검사**(누락 시 `portfolio_id_required` 거부) → **symbol resolve**(`SnapshotStore.get(portfolio_id).symbol`, universe 부재 시 `unknown_portfolio` 거부, dcy-011/REQ-007) → `find_allocatable_slot`(idle ∪ closed 후보를 `entry_count` asc → `updated_at` asc → `slot_id` 정렬, 최저 진입 우선; dcy-019/REQ-006/REQ-007) → `SlotService.transit_slot(slot.status→pending_buy, symbol=resolved, portfolio_id)`(후보가 closed 면 from_state=closed 직접 재할당) → Postgres durable commit. 후보 소진 시 `no_idle_slot` 거부. (buy payload 에 client symbol 없음) 5. admission 응답(`status, slot_id?, reason?`)을 `reply_to` 큐로 publish → manual ack. 6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다. 7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱. @@ -272,13 +272,14 @@ ## Flow: `slot 상태 전이 (transit_slot)` 1. 호출자가 `transit_slot(slot_id, from_state, to_state, reason, actor, **fields)` 를 호출한다. -2. (from_state, to_state) 가 8 전이 패밀리에 속하는지 먼저 검증한다(아니면 SlotTransitionError). +2. (from_state, to_state) 가 전이 패밀리(8 base[dcy-019 closed→pending_buy 포함] + *→error + error→{idle,holding})에 속하는지 먼저 검증한다(아니면 SlotTransitionError). 3. advisory lock 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError). 4. 현재 status == from_state 인지 확인한다(아니면 SlotStateMismatchError — 레이스 감지). -5. to_state 의미에 맞춰 점유 필드를 계산하고 슬롯 행을 update 한다. (closed/pending_buy -> idle 은 점유 초기화) -6. closed -> idle 복구면 (trading_date, symbol) 재진입 카운트를 +1 한다. -7. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다. -8. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지). +5. to_state 의미에 맞춰 점유 필드를 계산하고 슬롯 행을 update 한다. (closed/pending_buy -> idle 은 점유 초기화; closed -> pending_buy 직접 재할당도 점유 필드를 clean-slate 초기화 후 새 진입 필드 적용) +6. `→pending_buy`(idle→ / closed→ 모두) 면 슬롯 `entry_count` 를 +1 한다(dcy-019/REQ-002). +7. closed -> idle 복구면 (trading_date, symbol) 재진입 카운트를 +1 한다. +8. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다. +9. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지). ## Flow Constraints diff --git a/docs/srs/Data/req-006_postgresql-slot-persistence.md b/docs/srs/Data/req-006_postgresql-slot-persistence.md index 2733f52..a3d464e 100644 --- a/docs/srs/Data/req-006_postgresql-slot-persistence.md +++ b/docs/srs/Data/req-006_postgresql-slot-persistence.md @@ -191,3 +191,44 @@ #### Validation Plan - BAT-016: PG 게이트 마이그레이션 round-trip + 컬럼 계약(limit_price) + idempotency. + +### CHG-20260609-01 + +- Date: 2026-06-09 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-019/issue #18) 영속화 레이어에 slot-scoped `entry_count` 반영: 멱등 마이그레이션 008(`ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0`), `_SLOT_COLUMNS`/`update_slot`/`_row_to_slot` flat 매핑 +entry_count, 신규 `find_allocatable_slot`(idle ∪ closed, `ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1`) selector 추가(in-memory 동등 구현 포함). 동시성은 기존 advisory lock + 재시도 규약 보존(find 쿼리 FOR UPDATE 미사용 — find_idle_slot 와 동일). BAT-025 검증 완료 → Implemented 유지. + +#### Intent + +- buy admission 의 후보 확장(idle∪closed)·공정 정렬을 영속 store 에서 결정적으로 지원하고, slot row 에 진입 횟수를 durable 하게 보존. + +#### Acceptance Criteria Delta + +- Updated: + - AC-1 컬럼 계약: `daily_slot` 에 `entry_count` 컬럼 추가(마이그레이션 008, additive·멱등). `EXPECTED_SLOT_FIELDS` ↔ information_schema 드리프트 가드가 자동 반영. +- Added: + - `find_allocatable_slot(trading_date)` 가 idle ∪ closed 후보를 `entry_count → updated_at → slot_id` 정렬로 1건 반환(없으면 None). + +#### Impacted Area Delta + +- Modules: `trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql`(신규), `trex_core/adapters/persistence/postgresql.py`(_SLOT_COLUMNS/update_slot/_row_to_slot/find_allocatable_slot), `trex_core/domain/slot/repository.py`·`in_memory_repository.py`(selector 포트 + 동등 구현) +- Tests: tests/adapters/persistence/test_migration_and_drift.py(자동 반영), tests/domain/slot/test_slot_entry_count.py(selector 정렬) + +#### Delivery Trace Delta + +- Batches: BAT-025 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-025 에서 완료) +- Existing Implementation Invalidated: no (additive 컬럼·신규 selector — 기존 find_idle_slot/round-trip 보존) +- Status Recommendation: Implemented (change-req → BAT-025 재검증) +- Follow-up Action: confirm-req-implemented(BAT-025). + +#### Validation Plan + +- BAT-025: unit (in-memory selector 정렬·reallocation) green; 마이그레이션 008 round-trip + 드리프트 가드는 test DSN 환경에서 자동 검증(라이브 runtime DSN 미사용 — 파괴적 truncate 회피). diff --git a/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md b/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md index 54247ee..f584049 100644 --- a/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md +++ b/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md @@ -187,3 +187,42 @@ #### Validation Plan - BAT-018: 코드-문서 diff 대조. + +### CHG-20260609-01 + +- Date: 2026-06-09 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-019/issue #18) baseline 문서 정합: `docs/data-model.md` 에 daily_slot `entry_count` 필드(slot-scoped, daily_symbol_entries 와 구분)·Lifecycle `closed→pending_buy`·State Rules(전이 패밀리 8 base 포함, closed→pending_buy clean-slate) 반영, `docs/runtime-flows.md` 의 buy admission 흐름을 `find_allocatable_slot`(idle∪closed, entry_count 정렬)·closed 직접 재할당으로, transit_slot 흐름에 `→pending_buy 시 entry_count +1` 단계 반영. BAT-025 검증 완료 → Implemented 유지. + +#### Intent + +- entry_count + closed 재할당 도메인 변경 후 baseline 문서가 실제 흐름/데이터 모델과 일치하도록 유지. + +#### Acceptance Criteria Delta + +- Updated: + - AC-2: `docs/runtime-flows.md` admission 슬롯 선택 흐름이 idle∪closed 후보 selector·closed→pending_buy 재할당과 일치. + - AC-3: `docs/data-model.md` 에 daily_slot `entry_count` 필드 + lifecycle/transition 규칙이 반영. + +#### Impacted Area Delta + +- Docs: docs/data-model.md(daily_slot entry_count/lifecycle/state rules), docs/runtime-flows.md(buy admission selector + transit_slot entry_count step) + +#### Delivery Trace Delta + +- Batches: BAT-025 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-025 에서 완료 — 코드-문서 diff 대조) +- Existing Implementation Invalidated: no (문서 추가 정합 — 기존 서술 보존) +- Status Recommendation: Implemented (change-req → BAT-025 재검증) +- Follow-up Action: confirm-req-implemented(BAT-025). + +#### Validation Plan + +- BAT-025: 코드(state.py/service.py/intents.py/postgresql.py) ↔ 문서(data-model/runtime-flows) diff 대조. diff --git a/docs/srs/Interface/req-002_slot-pure-domain-port.md b/docs/srs/Interface/req-002_slot-pure-domain-port.md index ea5e1cd..029f9a1 100644 --- a/docs/srs/Interface/req-002_slot-pure-domain-port.md +++ b/docs/srs/Interface/req-002_slot-pure-domain-port.md @@ -15,7 +15,7 @@ 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) +4. Given `SlotService`, When 6 상태/9 base 전이(`closed→pending_buy` 포함, dcy-019)/advisory-lock 직렬화/`closed→idle` 재진입 +1/`→pending_buy` 시 slot `entry_count` +1(dcy-019)/`closed→pending_buy` clean-slate 점유 초기화/lock 보유 중 외부 I/O 금지 동작을 확인, Then 보존된다. (NFR-2 — 검증은 REQ-003) ## Impacted Area - Docs: docs/interface-contract.md, docs/data-model.md (계약 기준 — 큰 변경 없을 수 있음) @@ -191,3 +191,44 @@ #### Validation Plan - BAT-016: 전이별 set/clear 단위 + 컬럼 계약 가드(REQ-003) 정합. + +### CHG-20260609-01 + +- Date: 2026-06-09 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-019/issue #18) 슬롯 도메인에 (1) slot-scoped `Slot.entry_count`(기본 0) 필드 + `EXPECTED_SLOT_FIELDS` 추가, (2) 상태 머신 허용 전이에 `closed→pending_buy`(closed 슬롯 직접 재할당) 추가(8→9 base 전이), (3) `_apply_transition_fields` 에 closed→pending_buy clean-slate 점유 초기화 + 모든 `→pending_buy`(idle→/closed→) 시 `entry_count` +1 로직 반영. 기존 `daily_symbol_entries`(symbol-scoped, AC-3)는 무관·미변경. BAT-025 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 슬롯 자원을 고르게 재사용(최저 진입 우선)하고 idle 소진 시 closed 재할당을 허용하기 위해, 슬롯 단위 누적 진입 횟수를 도메인 정본에 영속하고 closed→pending_buy 직접 전이를 도메인 계약에 추가. + +#### Acceptance Criteria Delta + +- Updated: + - AC-4: "6 상태/8 전이" → "6 상태/9 base 전이(`closed→pending_buy` 포함)". 불변식 보존 대상에 `→pending_buy 시 entry_count +1`, `closed→pending_buy clean-slate 점유 초기화` 추가. +- Added: + - `Slot.entry_count`(int, 기본 0) 필드가 `EXPECTED_SLOT_FIELDS` 계약에 포함된다. + +#### Impacted Area Delta + +- Modules: `trex_core/domain/slot/models.py`(Slot.entry_count/EXPECTED_SLOT_FIELDS), `trex_core/domain/slot/state.py`(closed→pending_buy 전이), `trex_core/domain/slot/service.py`(clean-slate + entry_count +1) +- Tests: tests/domain/slot/test_state.py(전이 카운트 14→15), test_contract.py(필드셋), test_slot_entry_count.py(신규) + +#### Delivery Trace Delta + +- Batches: BAT-025 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-025 에서 완료) +- Existing Implementation Invalidated: no (additive — 기존 idle-only 동작은 closed 후보 부재 시 보존, 회귀 green) +- Status Recommendation: Implemented (change-req → BAT-025 재검증) +- Follow-up Action: confirm-req-implemented(BAT-025). + +#### Validation Plan + +- BAT-025: 전이 카운트/계약 가드 + entry_count 증분·보존 + closed→pending_buy clean-slate 단위 테스트 green; `daily_symbol_entries`(AC-3) 회귀 green. diff --git a/docs/srs/Interface/req-007_intent-submission-api.md b/docs/srs/Interface/req-007_intent-submission-api.md index e4d76a6..131c3aa 100644 --- a/docs/srs/Interface/req-007_intent-submission-api.md +++ b/docs/srs/Interface/req-007_intent-submission-api.md @@ -12,7 +12,7 @@ - `POST /v1/intents/buy` 와 `POST /v1/intents/sell` 를 제공한다. 코어는 admission/검증 후 슬롯 전이(idle→pending_buy / holding→pending_sell)를 advisory lock 하 단일 writer 로 수행하고, 동기로 admit/reject 를 응답한다. 수락된 intent 는 같은 트랜잭션에서 `intent_log`(intent_id, payload, decision, ts) 에 durable 기록하며 `intent_id` 로 dedup 한다. `GET /v1/intents/{intent_id}` 로 결정을 재조회한다. ## Acceptance Criteria -1. Given idle 슬롯, When `POST /v1/intents/buy`, Then admission 통과 시 idle→pending_buy 로 전이하고 `{admitted:true, slot_id}` 를 동기 응답한다. +1. Given 할당 가능 슬롯(idle ∪ closed, dcy-019), When `POST /v1/intents/buy`, Then `find_allocatable_slot`(entry_count→updated_at→slot_id 정렬)로 최저 진입 후보를 잡아 `slot.status→pending_buy`(idle→ 또는 closed→ 직접 재할당) 전이하고 `{admitted:true, slot_id}` 를 동기 응답한다. idle·closed 모두 소진 시 `no_idle_slot` 거부. 2. Given holding 슬롯, When `POST /v1/intents/sell`, Then 검증 통과 시 holding→pending_sell 로 전이하고 admit 결과를 동기 응답한다(거부 시 사유 포함). 3. Given 이미 처리된 `intent_id`, When 동일 intent 재전송, Then 슬롯이 중복 전이되지 않고 최초 결정과 동일한 응답을 돌려준다(멱등). (NFR-3) 4. Given 동일 슬롯에 대한 동시 intent, When 병렬 처리, Then advisory lock 으로 직렬화되어 단일 writer 불변식이 유지된다(매수·매도 전략은 슬롯을 직접 쓰지 않음). (NFR-2) @@ -273,3 +273,42 @@ #### Validation Plan - 실 데몬 기동(paper + test PG/RabbitMQ) + `trex-core enter pf-005930 --yes` → admitted/체결/ledger completed e2e + test_daemon_resolve_symbol 회귀. + +### CHG-20260609-01 + +- Date: 2026-06-09 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-019/issue #18) buy admission(`_admit_buy`) 의 슬롯 선택을 `find_idle_slot` → `find_allocatable_slot`(idle ∪ closed, entry_count→updated_at→slot_id 정렬)로 일반화하고, 후보 status 를 따르도록 transit from_state 를 하드코딩 `IDLE` → `slot.status` 로 변경(closed 후보면 `closed→pending_buy` 직접 재할당). 마지막 슬롯 경합 재시도 루프·멱등(intent_log dedup)·단일 writer 불변식 보존. BAT-025 검증 완료 → Implemented 유지. + +#### Intent + +- idle 소진 시에도 closed 슬롯을 재할당해 슬롯 자원을 효율적으로 회전시키고, 슬롯 사용을 진입 횟수 기준으로 고르게 분산. + +#### Acceptance Criteria Delta + +- Updated: + - AC-1: 후보 = idle ∪ closed, `find_allocatable_slot` 선택 + `slot.status→pending_buy` 전이(closed 직접 재할당 포함), 양쪽 소진 시 `no_idle_slot` 거부로 일반화. + +#### Impacted Area Delta + +- Modules: `trex_core/application/intents.py`(`_admit_buy`: find_allocatable_slot + from_state=slot.status) +- Tests: tests/application/test_buy_admission_reallocation.py(신규: closed 재할당 admit/idle 우선/양쪽 소진 reject), tests/application/test_intent_concurrency.py(경합 회귀 보존) + +#### Delivery Trace Delta + +- Batches: BAT-025 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-025 에서 완료) +- Existing Implementation Invalidated: no (closed 후보 부재 시 기존 idle-only 동작과 동일 — 회귀 green) +- Status Recommendation: Implemented (change-req → BAT-025 재검증) +- Follow-up Action: confirm-req-implemented(BAT-025). + +#### Validation Plan + +- BAT-025: closed 재할당 admit / idle 우선 / 양쪽 소진 reject 단위 + 동시성 경합 회귀 green. diff --git a/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md index 7e53649..fe9c21f 100644 --- a/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md +++ b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md @@ -191,3 +191,44 @@ #### Validation Plan - BAT-016: 도메인 필드셋 가드 + PG 컬럼 드리프트 가드 green. + +### CHG-20260609-01 + +- Date: 2026-06-09 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-019/issue #18) 슬롯 도메인 변경(REQ-002: entry_count 필드 + closed→pending_buy 전이)에 맞춰 테스트 계약 정합: 상태 머신 전이 카운트 14→15(`closed→pending_buy` allowed 이동), 컬럼 계약 가드가 `entry_count` 를 포함, entry_count 증분·보존/closed 재할당 selector 단위 테스트(test_slot_entry_count.py) 추가. BAT-025 검증 완료 → Implemented 유지. + +#### Intent + +- entry_count + closed 재할당 도메인 변경이 회귀 없이 계약(필드셋·전이 집합)으로 강제되도록 테스트를 동기화. + +#### Acceptance Criteria Delta + +- Updated: + - 상태 머신 전이 집합 테스트: `closed→pending_buy` 를 allowed 로, ALLOWED_TRANSITIONS 크기 14→15 로 단언. + - 컬럼 계약 가드: `EXPECTED_SLOT_FIELDS` 에 `entry_count` 포함을 검증. +- Added: + - entry_count 증분/보존, closed→pending_buy clean-slate, find_allocatable_slot 정렬 단위 테스트. + +#### Impacted Area Delta + +- Tests: tests/domain/slot/test_state.py, tests/domain/slot/test_contract.py, tests/domain/slot/test_slot_entry_count.py(신규), tests/application/test_buy_admission_reallocation.py(신규), tests/adapters/persistence/test_migration_and_drift.py(EXPECTED_SLOT_FIELDS 자동 반영) + +#### Delivery Trace Delta + +- Batches: BAT-025 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-025 에서 완료) +- Existing Implementation Invalidated: no (계약 테스트 확장 — 기존 단언 보존) +- Status Recommendation: Implemented (change-req → BAT-025 재검증) +- Follow-up Action: confirm-req-implemented(BAT-025). + +#### Validation Plan + +- BAT-025: 전이/계약 가드 + 신규 entry_count·reallocation 단위 테스트 green (unit), PG 게이트 드리프트 가드는 test DSN 환경에서 자동 검증. diff --git a/docs/srs/index.md b/docs/srs/index.md index 16c38e4..66dede9 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -139,9 +139,8 @@ ## Recent Change Log Summary +- 2026-06-09 :: REQ-002/003/006/007/009 change-req (CHG-20260609-01, dcy-019/issue #18, BAT-025): slot-scoped `entry_count` 추가 + closed 슬롯 직접 재할당. daily_slot/`Slot` 에 `entry_count`(기본 0) 필드 + `EXPECTED_SLOT_FIELDS` + 멱등 마이그레이션 008(REQ-002/006), 상태 머신 `closed→pending_buy` 신규 전이(8→9 base, ALLOWED_TRANSITIONS 14→15)(REQ-002), `_apply_transition_fields` closed→pending_buy clean-slate + 모든 `→pending_buy` 시 entry_count +1(REQ-002), `find_allocatable_slot`(idle∪closed, `entry_count→updated_at→slot_id`) selector + in-memory 동등(REQ-006), `_admit_buy` find_idle_slot→find_allocatable_slot + from_state=slot.status(REQ-007), 테스트 계약 정합(REQ-003), baseline 2문서(data-model/runtime-flows) 정합(REQ-009). 기존 `daily_symbol_entries`(symbol-scoped) 무관·미변경. 전부 change-req(신규 REQ 없음, OQ-1/2/3). 단위 366 passed(+KIS sandbox 1건은 live .env URL 로 사전 환경 실패·무관), ruff clean. PG 게이트 마이그레이션/드리프트는 라이브 runtime DSN 미사용(파괴적 truncate 회피)·test DSN 자동 검증. 전부 **Implemented** 유지. (change-req) - 2026-06-09 :: REQ-032 change-req→Implemented (CHG-20260609-01/02, issue #17, BAT-024): `trex-core enter` 에 `--forced-close-at ` (절대시간) 옵션 추가 — 운영자가 매수 진입 명령 하나로 시간 기반 자동청산(forced_close) 시각 지정. CLI 가 buy intent payload 의 기존 `forced_close_at` 필드에 매핑(계약 REQ-007/021·도메인 REQ-066·forced-close 루프 REQ-037 이미 구현·불변, CLI 노출 갭만 보완). 잘못된 형식 제출 전 거부, 미지정 시 미포함(하위호환). 상대시간(`--hold-minutes`) 미채택. AC-5/6 신규. batch-lite(BAT-024) 단위 검증 → **Implemented**. 단위 353 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-017/019/029 change-req + REQ-030 Approved→Implemented (dcy-018, BAT-023): ledger write 응답의 권위 cash/quantity 를 portfolio 스냅샷에 write-back(option D 활성화) — 매수/매도 직후 "수량 live·현금 stale" 비대칭 제거. `record_trade`→`LedgerWriteOutcome{result, *_after}`+어댑터 `cash_balance_after`/`asset_quantity_after` 파싱(new+duplicate)(REQ-017), `LedgerWriter` SnapshotStore write-back best-effort+`pending_cash_delta`(REQ-019), 추정 현금 오버레이(`cash_estimated` 라벨)+슬롯 오버레이 유지(REQ-029, OQ-1 B/OQ-4 i), option D 활성화로 REQ-030 Approved→Implemented. 전부 change-req(신규 REQ 없음, OQ-3 a). portfolio-hub#8(`*_after`) 동작 중. 단위 350 passed, 인프라 게이트 385 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-028 + REQ-032 change-req→Implemented (CHG-20260608-01/02 / -03/04, dcy-016 후속, BAT-022): `trex-core slots` 가 universe `assets`(portfolio)를 슬롯 상태로 오인 표시하던 갭 정정(10개 슬롯인데 universe 1개·status 없음 — BAT-007 HTTP 슬롯 read 제거 후 슬롯 상태 read 표면 부재가 근인). snapshot envelope 에 read-only per-slot `slots[]`(canonical `slot_to_dict`: slot_id/trading_date/status/symbol/qty/avg_price/entry_price/.../portfolio_id) additive 추가(REQ-028 AC-4, strategy `assets[]` 불변), `trex-core slots` 가 `slots[]`(실제 슬롯 상태) 표시·`--status` 슬롯 status 필터·`universe` 는 `assets[]` 유지(REQ-032). BAT-007 stub/BAT-008 envelope 슬롯 스키마 단일화. 신규 코드 데몬 + 실 인프라 운영 재현 PASS(slots=10·status·DB 일치·universe 분리·비침습). 단위 338 passed, ruff clean. pytest 인프라 게이트는 Human Approval Memo 수용. 둘 다 **Implemented**. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-034/021/037/038/039/032/042 change-req (dcy-017, BAT-021): 전략 이탈로 슬롯이 워치독(안전망) 소유로 넘어간 상태를 가시화(issue #12). snapshot RPC 응답에 holding 슬롯 워치독 상태(expired/last_seen) read-only 동봉(best-effort) + `WatchdogStore.last_seen`(REQ-034/021), `trex-core slots` WATCHDOG-OWNED 표시(REQ-032), safety-sell 워치독 소유 진입(WARN)/이탈(INFO)·발동(forced_close·stop_loss=WARN, take_profit·market_close=INFO) 로그+in-process 카운터(REQ-037/038/039), baseline 2문서 정합(REQ-042). best-effort(청산 무차단), Redis 미채택. 전부 change-req(신규 REQ 없음, OQ-3). 단위 335 passed, 인프라 게이트 369 passed, ruff clean. (change-req/confirm-req-implemented) -- 2026-06-08 :: REQ-032/022/021/034/026 change-req (dcy-016, BAT-020): 운영자 CLI 확장 — `universe`/`slots`(snapshot RPC)·`enter`(buy intent RPC)·`exit`(sell intent RPC) 추가(전부 데몬 RPC 통일, 단일 writer 보존, DB 직접 쓰기 없음)(REQ-032), CLI request-reply RPC 클라이언트 재사용(REQ-022), snapshot 요청 워치독 억제 플래그(`touch_watchdog`/`actor="operator"`)로 운영자 조회가 전략 생존 신호로 오인되지 않게(REQ-021/034, R-4), enter/exit 안전장치(확인 프롬프트/`--yes`/live 이중 ack/`--dry-run`)+`--json`+인스턴스 라우팅, baseline 2문서 정합(REQ-026). pause/resume 는 direct-DB 유지. 전부 change-req(신규 REQ 없음, OQ-3). 단위 327 passed, 인프라 게이트 361 passed, ruff clean. (change-req/confirm-req-implemented) -- 2026-06-08 :: REQ-017/019/018/020 change-req (dcy-015, BAT-019): portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도되던 버그(issue #13) 정정. 404/400/422→`LedgerPermanentError`(REQ-017), outbox 영속→터미널 `delivery_failed` 즉시 + retryable 지수 백오프+jitter+`next_retry_at`+max_attempts→dead-letter + `list_pending` 백오프 스킵 + 마이그레이션 007(status enum/컬럼) + 백오프 env(REQ-019), 터미널 WARN+카운터 가시화(REQ-018), baseline 3문서 정합(REQ-020). 전부 change-req(신규 REQ 없음, OQ-3). 단위 311 passed, 인프라 게이트 345 passed, ruff clean. (change-req/confirm-req-implemented) diff --git a/tests/application/test_buy_admission_reallocation.py b/tests/application/test_buy_admission_reallocation.py new file mode 100644 index 0000000..f1ed2ba --- /dev/null +++ b/tests/application/test_buy_admission_reallocation.py @@ -0,0 +1,85 @@ +"""dcy-019 / BAT-025: buy admission reallocates closed slots (REQ-007). + +When no idle slot remains, _admit_buy must admit into a CLOSED slot via the new +closed→pending_buy transition, ordered by entry_count (least-entered first). +""" +from __future__ import annotations + +from datetime import date + +from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore +from trex_core.application.intents import BuyIntent, IntentService +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +DAY = date(2026, 6, 9) + + +def _cycle_to_closed(svc, slot_id): + svc.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930") + svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", avg_price=70_000) + svc.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="r", actor="a") + svc.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED, + reason="r", actor="a") + + +def test_admits_into_closed_slot_when_no_idle_remains(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + intents = IntentService(svc, repo, InMemoryIntentStore()) + svc.preallocate_daily_slots(DAY, 1) + slot = repo.find_idle_slot(DAY) + _cycle_to_closed(svc, slot.slot_id) + assert repo.find_idle_slot(DAY) is None # nothing idle + + d = intents.submit_buy(BuyIntent( + intent_id="buy-realloc", trading_date=DAY, + symbol="000660", qty=3, portfolio_id="PF-1", + )) + assert d.status == "admitted" + realloc = repo.fetch_slot(d.slot_id) + assert realloc.status is SlotState.PENDING_BUY + assert realloc.symbol == "000660" + # second entry into the same physical slot + assert realloc.entry_count == 2 + + +def test_rejects_when_no_idle_and_no_closed(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + intents = IntentService(svc, repo, InMemoryIntentStore()) + svc.preallocate_daily_slots(DAY, 1) + slot = repo.find_idle_slot(DAY) + # take it to holding (not closed, not idle → not allocatable) + svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930") + svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", avg_price=70_000) + + d = intents.submit_buy(BuyIntent( + intent_id="buy-none", trading_date=DAY, + symbol="000660", portfolio_id="PF-1", + )) + assert d.status == "rejected" + assert d.reason == "no_idle_slot" + + +def test_prefers_idle_over_closed(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + intents = IntentService(svc, repo, InMemoryIntentStore()) + svc.preallocate_daily_slots(DAY, 2) + slots = repo.list_slots(DAY) + _cycle_to_closed(svc, slots[0].slot_id) # one closed (entry_count=1) + + d = intents.submit_buy(BuyIntent( + intent_id="buy-prefer-idle", trading_date=DAY, + symbol="000660", portfolio_id="PF-1", + )) + assert d.status == "admitted" + chosen = repo.fetch_slot(d.slot_id) + # the fresh idle slot (entry_count 0→1) is preferred over the closed one + assert chosen.entry_count == 1 diff --git a/tests/domain/slot/test_slot_entry_count.py b/tests/domain/slot/test_slot_entry_count.py new file mode 100644 index 0000000..4c973b6 --- /dev/null +++ b/tests/domain/slot/test_slot_entry_count.py @@ -0,0 +1,165 @@ +"""dcy-019 / BAT-025: slot-scoped entry_count + closed-slot reallocation. + +Covers REQ-002 (entry_count field + closed→pending_buy clean-slate transition) +and REQ-006 (find_allocatable_slot selector ordering over idle ∪ closed). +""" +from __future__ import annotations + +from datetime import date + +import pytest + +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + + +@pytest.fixture +def trading_day(): + return date(2026, 6, 9) + + +@pytest.fixture +def repo(): + return InMemorySlotRepository() + + +@pytest.fixture +def service(repo): + return SlotService(repo) + + +def _cycle_to_closed(service, slot_id, symbol="005930"): + """Drive a slot idle→pending_buy→holding→pending_sell→closed.""" + service.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol=symbol) + service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", avg_price=70_000) + service.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="r", actor="a") + service.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED, + reason="r", actor="a") + + +# ---------------------------------------------------------------------------- +# entry_count increment (REQ-002 FR-4) +# ---------------------------------------------------------------------------- +class TestEntryCount: + def test_new_slot_starts_at_zero(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + assert slot.entry_count == 0 + + def test_increments_on_each_pending_buy(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + updated = service.transit_slot( + slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930", + ) + assert updated.entry_count == 1 + + def test_not_incremented_on_non_buy_transitions(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930") + held = service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a") + # holding entry does not bump the per-slot entry counter + assert held.entry_count == 1 + + def test_preserved_across_closed_to_idle_recovery(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + _cycle_to_closed(service, slot.slot_id) + recovered = service.transit_slot(slot.slot_id, SlotState.CLOSED, SlotState.IDLE, + reason="recovery", actor="a") + # entry_count is slot-scoped cumulative; recovery preserves it + assert recovered.entry_count == 1 + + +# ---------------------------------------------------------------------------- +# closed → pending_buy direct reallocation (REQ-002 FR / state machine #9) +# ---------------------------------------------------------------------------- +class TestClosedReallocation: + def test_closed_to_pending_buy_clean_slate_and_bumps_count( + self, service, repo, trading_day, + ): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + _cycle_to_closed(service, slot.slot_id, symbol="005930") + closed = repo.fetch_slot(slot.slot_id) + assert closed.status is SlotState.CLOSED + assert closed.entry_count == 1 + + realloc = service.transit_slot( + slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY, + reason="reallocate", actor="a", + symbol="000660", qty=5, allocated_capital=500_000, + limit_price=120_000, strategy_id="orb_v2", + ) + # new entry's fields applied + assert realloc.status is SlotState.PENDING_BUY + assert realloc.symbol == "000660" + assert realloc.qty == 5 + assert realloc.limit_price == 120_000 + assert realloc.strategy_id == "orb_v2" + # entry_count cumulative across reallocation (1 → 2) + assert realloc.entry_count == 2 + + def test_closed_to_pending_buy_clears_prior_position_when_not_supplied( + self, service, repo, trading_day, + ): + """A reallocation that omits a field must not leak the prior cycle's value.""" + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + # first cycle sets stop_loss/take_profit, then closes + service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, + reason="r", actor="a", symbol="005930", + stop_loss_price=60_000, take_profit_price=80_000) + service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="r", actor="a", avg_price=70_000) + 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") + # reallocate WITHOUT thresholds → must be cleared, not inherited + realloc = service.transit_slot( + slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY, + reason="reallocate", actor="a", symbol="000660", + ) + assert realloc.symbol == "000660" + assert realloc.stop_loss_price is None + assert realloc.take_profit_price is None + assert realloc.avg_price is None + + +# ---------------------------------------------------------------------------- +# find_allocatable_slot selector ordering (REQ-006, OQ-2) +# ---------------------------------------------------------------------------- +class TestAllocatableSelector: + def test_returns_none_when_no_idle_or_closed(self, service, repo, trading_day): + assert repo.find_allocatable_slot(trading_day) is None + + def test_prefers_idle_when_only_idle_present(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 3) + cand = repo.find_allocatable_slot(trading_day) + assert cand is not None and cand.status is SlotState.IDLE + + def test_includes_closed_when_no_idle_remains(self, service, repo, trading_day): + service.preallocate_daily_slots(trading_day, 1) + slot = repo.find_idle_slot(trading_day) + _cycle_to_closed(service, slot.slot_id) + # no idle left, but the closed slot is allocatable + cand = repo.find_allocatable_slot(trading_day) + assert cand is not None and cand.status is SlotState.CLOSED + + def test_orders_by_entry_count_ascending(self, service, repo, trading_day): + """A closed slot (entry_count=1) loses to a fresh idle slot (entry_count=0).""" + service.preallocate_daily_slots(trading_day, 2) + slots = repo.list_slots(trading_day) + _cycle_to_closed(service, slots[0].slot_id) # this one now entry_count=1, closed + # the still-idle slot has entry_count 0 and must be chosen first + cand = repo.find_allocatable_slot(trading_day) + assert cand.entry_count == 0 + assert cand.status is SlotState.IDLE diff --git a/tests/domain/slot/test_state.py b/tests/domain/slot/test_state.py index 8b7635a..07483fe 100644 --- a/tests/domain/slot/test_state.py +++ b/tests/domain/slot/test_state.py @@ -43,6 +43,8 @@ def test_advisory_lock_id_is_stable_constant(): (SlotState.PENDING_SELL, SlotState.CLOSED), (SlotState.PENDING_SELL, SlotState.HOLDING), (SlotState.CLOSED, SlotState.IDLE), + # 9. closed-slot direct reallocation (dcy-019 / BAT-025) + (SlotState.CLOSED, SlotState.PENDING_BUY), # 8. * -> error (SlotState.IDLE, SlotState.ERROR), (SlotState.PENDING_BUY, SlotState.ERROR), @@ -72,7 +74,6 @@ def test_allowed_transition(from_state: SlotState, to_state: SlotState): (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), @@ -92,5 +93,5 @@ def test_self_loops_are_not_allowed(): def test_allowed_transitions_set_size(): - """7 base + 5 to-error + 2 operator-resolve = 14 transitions.""" - assert len(ALLOWED_TRANSITIONS) == 14 + """8 base (incl. dcy-019 closed->pending_buy) + 5 to-error + 2 operator-resolve = 15.""" + assert len(ALLOWED_TRANSITIONS) == 15 diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index 2e9fad2..a09f42c 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -30,7 +30,7 @@ _SLOT_COLUMNS = ( "slot_id, trading_date, status, symbol, qty, avg_price, limit_price, " "allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " - "created_at, updated_at" + "created_at, updated_at, entry_count" ) @@ -116,6 +116,21 @@ class PostgreSQLSlotRepository: row = cur.fetchone() return self._row_to_slot(row) if row else None + def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: + # dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc + # → updated_at asc → slot_id (deterministic, OQ-2). Concurrency on the + # last candidate handled by the caller (advisory lock + retry, NFR-1). + with self._psycopg.connect(self._dsn) as conn: + with conn.cursor() as cur: + cur.execute( + f"SELECT {_SLOT_COLUMNS} FROM daily_slot " + "WHERE trading_date = %s AND status IN ('idle','closed') " + "ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1", + (trading_date,), + ) + row = cur.fetchone() + return self._row_to_slot(row) if row else None + def update_slot(self, slot: Slot) -> None: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: @@ -124,13 +139,14 @@ class PostgreSQLSlotRepository: "limit_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " "forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, " - "updated_at=%s WHERE slot_id=%s", + "entry_count=%s, updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, slot.qty, slot.avg_price, slot.limit_price, slot.allocated_capital, slot.order_id, slot.strategy_id, slot.broker_order_id, slot.portfolio_id, slot.sell_strategy_id, slot.forced_close_at, slot.stop_loss_price, slot.take_profit_price, + slot.entry_count, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), ) @@ -239,7 +255,7 @@ class PostgreSQLSlotRepository: # avg_price(5) limit_price(6) allocated_capital(7) order_id(8) # strategy_id(9) broker_order_id(10) portfolio_id(11) sell_strategy_id(12) # forced_close_at(13) stop_loss_price(14) take_profit_price(15) - # created_at(16) updated_at(17) + # created_at(16) updated_at(17) entry_count(18) return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), trading_date=row[1], @@ -259,6 +275,7 @@ class PostgreSQLSlotRepository: take_profit_price=d(row[15]), created_at=row[16], updated_at=row[17], + entry_count=int(row[18]) if row[18] is not None else 0, ) diff --git a/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql b/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql new file mode 100644 index 0000000..9d198bb --- /dev/null +++ b/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql @@ -0,0 +1,7 @@ +-- BAT-025 / dcy-019 / REQ-006: slot-scoped entry_count on daily_slot. +-- Tracks how many times a slot has entered pending_buy (lifetime, per trading +-- day). Drives the buy-admission selector's fairness ordering (idle ∪ closed, +-- least-entered first) and enables closed-slot reallocation (issue #18). +-- Distinct from daily_symbol_entries.entry_count (per-symbol cap, unchanged). +-- Additive + idempotent: existing rows default to 0 (no prior entries counted). +ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0; diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index b4a053c..53e24b1 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -163,16 +163,20 @@ class IntentService: elif not symbol: # No resolver wired and no symbol carried — cannot determine the order target. return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") - # Retry on the last-idle-slot race: another intent may claim the slot + # Retry on the last-candidate race: another intent may claim the slot # between our find and transit (SlotStateMismatchError). + # dcy-019 / REQ-007: admission candidate is idle ∪ closed (closed-slot + # reallocation), ordered by entry_count asc → updated_at asc → slot_id. + # The candidate may already be CLOSED, so the transit's from_state must + # follow the slot's current status rather than a hardcoded IDLE. for _ in range(_MAX_IDLE_RETRY): - slot = self._repo.find_idle_slot(intent.trading_date) + slot = self._repo.find_allocatable_slot(intent.trading_date) if slot is None: return IntentDecision(intent.intent_id, "rejected", None, "no_idle_slot") try: updated = self._svc.transit_slot( slot.slot_id, - SlotState.IDLE, + slot.status, SlotState.PENDING_BUY, reason="buy_intent", actor="intent_api", diff --git a/trex_core/domain/slot/in_memory_repository.py b/trex_core/domain/slot/in_memory_repository.py index bf4a98c..a4c17a8 100644 --- a/trex_core/domain/slot/in_memory_repository.py +++ b/trex_core/domain/slot/in_memory_repository.py @@ -76,6 +76,25 @@ class InMemorySlotRepository: return s return None + def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: + # dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc + # → updated_at asc → slot_id (deterministic, OQ-2). + candidates = [ + s for s in self._slots.values() + if s.trading_date == trading_date + and s.status in (SlotState.IDLE, SlotState.CLOSED) + ] + if not candidates: + return None + return min( + candidates, + key=lambda s: ( + s.entry_count, + s.updated_at or datetime.min.replace(tzinfo=timezone.utc), + str(s.slot_id), + ), + ) + def update_slot(self, slot: Slot) -> None: if slot.slot_id not in self._slots: raise SlotNotFoundError(f"slot not found: {slot.slot_id}") diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index 0e4b170..8ee3308 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -39,6 +39,11 @@ class Slot: forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage) stop_loss_price: Optional[Decimal] = None # REQ-035 / BAT-010 take_profit_price: Optional[Decimal] = None # REQ-035 / BAT-010 + # dcy-019 / REQ-002 (BAT-025): slot-scoped cumulative entry count. +1 on each + # allocation transition (idle/closed → pending_buy); drives admission slot + # selection (lowest entry_count first). Distinct from the symbol-scoped + # daily_symbol_entries counter (REQ-002 AC-3) — different key/purpose. + entry_count: int = 0 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -64,6 +69,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "forced_close_at", "stop_loss_price", # REQ-035 "take_profit_price", # REQ-035 + "entry_count", # dcy-019 / REQ-002 (BAT-025) "created_at", "updated_at", }) diff --git a/trex_core/domain/slot/repository.py b/trex_core/domain/slot/repository.py index 0418b1e..bd42c1f 100644 --- a/trex_core/domain/slot/repository.py +++ b/trex_core/domain/slot/repository.py @@ -52,6 +52,13 @@ class SlotRepository(Protocol): """Return any one idle slot (None if exhausted).""" ... + def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: + """Return the best buy-admission candidate among idle ∪ closed slots + (dcy-019 / REQ-006), ordered by `entry_count` asc → `updated_at` asc → + `slot_id`. None if neither idle nor closed slots remain. Concurrency on + the last candidate is handled by the caller (advisory lock + retry).""" + ... + def update_slot(self, slot: Slot) -> None: """Persist slot row (UPDATE by slot_id).""" ... diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index fce948d..120a33b 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -201,6 +201,19 @@ class SlotService: updated_at=datetime.now(tz=timezone.utc), ) + # dcy-019 / REQ-002 (BAT-025): closed-slot direct reallocation. A closed + # slot still carries the prior cycle's occupancy; start from a clean + # slate so a previous position/broker handle never leaks into the new + # entry. entry_count is intentionally preserved (slot-scoped cumulative). + if to_state is SlotState.PENDING_BUY and slot.status is SlotState.CLOSED: + slot = replace( + slot, + symbol=None, qty=None, avg_price=None, limit_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, stop_loss_price=None, take_profit_price=None, + ) + # limit_price lifecycle (dcy-012 FR-1): set only on entering a pending # state (the order's limit); cleared on entering HOLDING/CLOSED. if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}: @@ -208,6 +221,10 @@ class SlotService: else: # HOLDING / CLOSED — pending order limit no longer applies. new_limit_price = None + # dcy-019 / REQ-002: +1 on each allocation transition (→pending_buy), + # for both idle→pending_buy and closed→pending_buy (FR-4). + new_entry_count = slot.entry_count + (1 if to_state is SlotState.PENDING_BUY else 0) + # Generic update: caller-supplied fields override; None means "keep". return replace( slot, @@ -241,6 +258,7 @@ class SlotService: take_profit_price=( take_profit_price if take_profit_price is not None else slot.take_profit_price ), + entry_count=new_entry_count, # dcy-019 / REQ-002 updated_at=datetime.now(tz=timezone.utc), ) diff --git a/trex_core/domain/slot/state.py b/trex_core/domain/slot/state.py index 4373262..722af37 100644 --- a/trex_core/domain/slot/state.py +++ b/trex_core/domain/slot/state.py @@ -1,6 +1,7 @@ -"""Slot state machine: 6 states + 8 allowed transitions (REQ-001, REQ-002). +"""Slot state machine: 6 states + 9 base allowed transitions (REQ-001, REQ-002). -The transitions match dcy-001 FR-2 and BAT-001 design. +The transitions match dcy-001 FR-2 and BAT-001 design, extended by dcy-019 with +`closed -> pending_buy` (closed-slot direct reallocation, BAT-025). """ from __future__ import annotations @@ -24,8 +25,8 @@ class SlotState(str, Enum): ERROR = "error" -# 8 allowed transitions per dcy-001 FR-2. Any pair not in this set must be -# rejected by SlotService. +# 9 base allowed transitions (dcy-001 FR-2 + dcy-019). 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( { @@ -36,6 +37,7 @@ _BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( (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) + (SlotState.CLOSED, SlotState.PENDING_BUY), # 9. closed-slot direct reallocation (dcy-019/BAT-025) } )