diff --git a/docs/batches/bat-042_20260615_slot-strategy-state-store/design.md b/docs/batches/bat-042_20260615_slot-strategy-state-store/design.md new file mode 100644 index 0000000..50b7d1f --- /dev/null +++ b/docs/batches/bat-042_20260615_slot-strategy-state-store/design.md @@ -0,0 +1,49 @@ +# BAT-042 Design + +- Status: confirmed +- Batch: BAT-042 + +## Architecture Summary +- strategy-state 는 슬롯 본체와 분리된 저장 관심사다. 별도 `slot_strategy_state` 테이블 + `StrategyStateStore` 포트로 격리하고, 슬롯 행에는 정합성 가드용 `occupancy_seq` 컬럼 1개만 추가한다(full 3계층 분할은 후속). read 는 기존 `trex.slots.req` 에 piggyback(추가 왕복 0), write 는 신규 RPC `trex.slots.strategy_state.set` 로 분리(evaluate 순수성·재시도 격리). 점유 전환 ABA 는 write CAS 의 occupancy_seq 검증으로 구조적으로 차단한다. trex-core 는 `data` 를 해석하지 않는다(opaque UTF-8 + size cap 만 검증). + +## Changed Areas +- Docs: docs/runtime-flows.md, docs/project-structure.md, docs/interface-contract.md, docs/data-model.md (REQ-082) +- Modules: + - `trex_core/domain/slot/models.py` — `Slot.occupancy_seq: int = 0` + EXPECTED_SLOT_FIELDS + - `trex_core/domain/slot/service.py` — `inactive→pending_buy` 시 occupancy_seq++ / `→INACTIVE` 시 StrategyStateStore.delete(slot_id) 훅 + - 신규 `trex_core/application/strategy_state.py`(또는 ports.py 확장) — `StrategyStateStore` Protocol + `InMemoryStrategyStateStore` + - `trex_core/adapters/persistence/postgresql.py` — `PgStrategyStateStore` + 마이그레이션 SQL(occupancy_seq 컬럼, slot_strategy_state 테이블) + - `trex_core/adapters/messaging/topology.py` — `STRATEGY_STATE_SET_QUEUE` + per-instance queue helper + - `trex_core/application/messaging_handlers.py` — set 핸들러(store 위임) + slots 핸들러 strategy_state batched 조립 + - `trex_core/application/portfolio.py` — slot_to_dict 에 occupancy_seq + strategy_state 맵 + - `trex_core/runtime/daemon.py` — store 생성/와이어링 + set 핸들러 consumer +- Interfaces: `trex.slots.strategy_state.set` RPC(신규), `trex.slots.req` 응답 행 additive 확장 + +## Key Decisions +1. **저장 = 별도 테이블 + slot_id 키 + 맵**(dcy-028 OQ-4): `slot_strategy_state(slot_id, strategy_id, occupancy_seq, version, data, updated_at)` 복합 PK `(slot_id, strategy_id)`. JSONB-on-row 기각. occupancy_seq 는 행에 함께 저장(write 시 슬롯 현재값과 비교용 스냅샷이 아니라, store CAS 가 슬롯 현재 occupancy_seq 를 인자로 받아 비교). +2. **CAS 의미**(REQ-079/080): `set(slot_id, strategy_id, expected_occupancy_seq, expected_version, data, *, current_occupancy_seq, slot_active)` → + - slot_active=False → `slot_inactive` + - current_occupancy_seq ≠ expected_occupancy_seq → `occupancy_changed` + - size 초과 → `state_too_large` + - stored version ≠ expected_version → `version_conflict` + - 일치(없으면 expected_version=0) → commit, version+1. +3. **occupancy_seq bump 지점**(REQ-081): 슬롯이 `inactive→pending_buy` 로 전이할 때 ++. 한 점유 구간(pending_buy→holding→pending_sell) 동안 불변. 실패 매수 롤백(pending_buy→inactive)으로 번호가 건너뛰어도 monotonic 보장이면 무방. +4. **close 삭제 훅**(REQ-081/C-3): `service.py` 의 `→INACTIVE` 단일 분기(position 필드 clear 위치)에서 store.delete(slot_id). 모든 close 경로(전략 매도/safety-sell/forced-close/operator) 자동 커버. 행 삭제 아님. +5. **read 비대칭**(OQ-2): read 는 slots 핸들러가 반환 slot_id 들로 store.get_many(slot_ids) batched 조회 → group by → 행별 맵 조립. write 만 별도 RPC. occupancy_seq 는 슬롯 행 컬럼이라 slot_to_dict 에서 직접 노출. +6. **opaque**(OQ-1): data 는 UTF-8 str, core 무해석. size cap byte 기준 — 기본 상한 상수(예: `_STATE_CAP_BYTES`), 환경변수 override 여지(REQ 단계 확정). 기본 2KB, ceiling 4KB → 단일 cap 4KB 로 구현하고 1~2KB 권장은 문서 가이드(운영). 구현 cap = 4KB(byte) hard. + +## Edge Cases +- first-create 시 expected_version=0 + occupancy_seq 일치 → version=1. occupancy 불일치면 first-create 도 `occupancy_changed`. +- 동일 correlation_id 재전송 → 동일 expected_version 이라 commit 후 재요청은 version 이 이미 +1 → `version_conflict`(멱등적으로 무부작용). 또는 store가 동일 (occ,ver,data) 재적용을 무시. +- close 직후 inactive 슬롯에 write → `slot_inactive`(저장 안 함, 부활 차단). +- occupancy_seq 오버플로우: int(8 byte PG bigint 권장) — 실질 무한. +- 빈 strategy_state 슬롯 read → 행에 strategy_state omit/`{}`. +- in-memory store: dict[(slot_id, strategy_id)] = (occupancy_seq, version, data). 슬롯 current occupancy_seq 는 slot repo 에서 조회. + +## Architecture Impact +- both (project-structure + runtime-flows) + +## Reference Doc Update Plan +- project-structure.md: `slot_strategy_state` 테이블·`StrategyStateStore`(포트+PG/in-memory) 위치, slot `occupancy_seq` 컬럼 추가 반영 (REQ-082). +- runtime-flows.md: strategy-state read piggyback / write RPC / occupancy_seq·version CAS / 점유 가드(occupancy_changed) / close 삭제 흐름 신설 (REQ-082). +- interface-contract.md: `trex.slots.strategy_state.set` 계약(요청/응답/reject reason) + slots.req 행의 strategy_state·occupancy_seq 필드 (REQ-082). diff --git a/docs/batches/bat-042_20260615_slot-strategy-state-store/implementation.md b/docs/batches/bat-042_20260615_slot-strategy-state-store/implementation.md new file mode 100644 index 0000000..a6bd36f --- /dev/null +++ b/docs/batches/bat-042_20260615_slot-strategy-state-store/implementation.md @@ -0,0 +1,39 @@ +# BAT-042 Implementation + +- Status: completed +- Batch: BAT-042 + +## Plan Summary +- M1~M6 순서로 구현. in-memory store 우선 + PG 구현 병행, 각 단계 테스트 동반. paper/in-memory 로 무중단(live DB 비대상). + +## Changed Files +- `trex_core/domain/slot/models.py` — `Slot.occupancy_seq: int = 0` + `EXPECTED_SLOT_FIELDS`에 `occupancy_seq` (M1) +- `trex_core/domain/slot/service.py` — `inactive→pending_buy` 시 occupancy_seq++ / `→INACTIVE` 시 `strategy_state_store.delete_all(slot_id)` 훅 + 생성자 `strategy_state_store` 주입 (M1/M3) +- `trex_core/application/strategy_state.py` (신규) — `StrategyStateStore` Protocol, `StrategyStateEntry`/`SetResult`, `_cas` 공용 의사결정, `InMemoryStrategyStateStore`, reject reason 상수, size cap (M2) +- `trex_core/adapters/persistence/postgresql.py` — `_SLOT_COLUMNS`/update/`_row_to_slot`에 occupancy_seq + `PgStrategyStateStore`(SELECT FOR UPDATE CAS, batched get, delete) (M1/M2) +- `trex_core/adapters/persistence/sql/014_add_slot_occupancy_seq.sql`, `015_create_slot_strategy_state.sql` (신규 마이그레이션) (M1/M2) +- `trex_core/application/portfolio.py` — `slot_to_dict`에 `occupancy_seq` 노출 (M5) +- `trex_core/application/messaging_handlers.py` — slots 핸들러 strategy_state batched 조립(`strategy_state_store` 파라미터) + `make_strategy_state_set_handler`(write RPC, reject reason) (M4/M5) +- `trex_core/adapters/messaging/topology.py` — `STRATEGY_STATE_SET_QUEUE` + `strategy_state_set_queue()` (M4) +- `trex_core/adapters/messaging/rabbitmq.py` — declare_topology 에 strategy_state_set 큐 선언/바인딩 (M4) +- `trex_core/runtime/daemon.py` — `PgStrategyStateStore` 생성, SlotService 주입, slots 핸들러 wiring, `_strategy_state_set_loop` consumer 스레드 (M3/M4/M5) +- `trex_core/runtime/reset.py` — `slot_strategy_state` truncate 목록 추가 +- 테스트: `tests/application/test_strategy_state_store.py`, `tests/application/test_strategy_state_set_handler.py`, `tests/application/test_slots_handler_strategy_state.py`, `tests/domain/slot/test_occupancy_seq_and_close_delete.py`, `tests/adapters/persistence/test_postgresql_repository.py`(PG 게이트 추가) + +## Execution Log +1. M1: Slot.occupancy_seq 필드 + EXPECTED_SLOT_FIELDS + service bump(inactive→pending_buy) + PG 컬럼/마이그레이션 014. +2. M2: strategy_state.py(포트+in-memory+_cas+cap) + PgStrategyStateStore + 마이그레이션 015. +3. M3: service `→INACTIVE` delete_all 훅 + 생성자 주입. +4. M4: topology 큐 + set 핸들러(store 위임) + rabbitmq declare + daemon consumer 스레드. +5. M5: slot_to_dict occupancy_seq + slots 핸들러 strategy_state batched 조립(occupancy 필터). +6. M6: baseline 문서(REQ-082)는 verification 직후 정합 — 코드 확정 후 별도 반영(별도 커밋). + +## Validation +- `TREX_BROKER=paper uv run pytest -q` → **479 passed, 47 skipped**(PG 게이트는 live DSN 없어 skip). 신규 33 테스트 포함(store CAS 6 케이스+ABA, occupancy_seq bump/close 삭제 모든 경로, set RPC reason 집합, slots read 조립+occupancy_seq+stale 필터). +- `uv run ruff check trex_core/ tests/` → All checks passed. +- 회귀: 기존 slots 핸들러/슬롯 서비스/계약(EXPECTED_SLOT_FIELDS) 테스트 전부 green. + +## Remaining Risks +- M6 baseline 문서(REQ-082)는 본 구현 직후 반영 예정 — 현재 미반영(verification 에서 추적). +- PG 경로(PgStrategyStateStore, 마이그레이션 014/015)는 live DSN 테스트가 CI 에서 skip — 게이트 테스트는 작성됨, 실 DB 검증은 release 운영 단계. +- 단일 writer 전제(dcy-028) — 다중 writer 동시성은 범위 밖. diff --git a/docs/batches/bat-042_20260615_slot-strategy-state-store/index.md b/docs/batches/bat-042_20260615_slot-strategy-state-store/index.md new file mode 100644 index 0000000..472546c --- /dev/null +++ b/docs/batches/bat-042_20260615_slot-strategy-state-store/index.md @@ -0,0 +1,35 @@ +# BAT-042: Slot strategy-state 저장소 — opaque state store + occupancy_seq 가드 (dcy-028) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-028 (issue #26 C-1/C-2/C-2b/C-3) +- Included REQ: REQ-079, REQ-080, REQ-081, REQ-082, REQ-073 (CHG-20260615-01), REQ-074 (CHG-20260615-01) +- Excluded REQ: 없음 + +## Summary +- trex-tsl 등 전략 데몬이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 영속화하는 저장소를 완성한다. 별도 `slot_strategy_state` 테이블 + `StrategyStateStore`(REQ-079), write RPC `trex.slots.strategy_state.set`(REQ-080), 점유 전환 ABA를 막는 occupancy_seq 가드 + close 삭제 불변식(REQ-081), read piggyback(REQ-073/074 CHG — slots.req 행에 strategy_state 맵·occupancy_seq additive), baseline 문서 정합(REQ-082). + +## Scope +- In Scope: + - REQ-079: `slot_strategy_state` 테이블(복합 PK (slot_id, strategy_id)) + `StrategyStateStore` 포트 + PG/in-memory 구현 + occupancy_seq·version CAS + opaque UTF-8 data + 2단 size cap. + - REQ-080: `trex.slots.strategy_state.set` write RPC — 요청/응답 shape, CAS, reject reason 집합(occupancy_changed/version_conflict/slot_inactive/state_too_large/bad_request), 멱등. + - REQ-081: `Slot.occupancy_seq` 필드 + EXPECTED_SLOT_FIELDS + 마이그레이션 + `inactive→pending_buy` bump + `→INACTIVE` strategy_state 삭제 훅. + - REQ-073/074 CHG: slots.req 응답 행에 strategy_state 맵(batched 조립)·occupancy_seq additive 노출. + - REQ-082: runtime-flows/project-structure/interface-contract baseline 정합. +- Out of Scope: + - C-4(SellIntent/watchdog) 신규 구현 — 기존 계약 확인(issue #26 답변 완료). + - full 3계층 슬롯 구조 분할(occupancy_id 키 전환) + daily_slot 네이밍 + daily_symbol_entries 잔재 제거 — 후속 "영구 슬롯 구조 정합" Discovery. + - 다중 writer 동시성 제어(단일 writer 전제), strategy_state 장기 보존/감사. + - optional batch set RPC 변형. + +## 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-028). 신규 테이블·도메인 필드·RPC·마이그레이션 + 기존 read 표면 확장(REQ-073/074 CHG) + baseline 정합 복합 → standard profile. +- REQ-073/074 는 CHG 로 Implemented→Approved 되돌림(additive AC, 기존 동작 보존). 본 batch 에서 신규 AC(073-AC7/074-AC6) 구현·검증 후 confirm-req-implemented 로 Implemented 복귀. +- NFR-1(opaque)은 FR 흡수, NFR-2(size cap)는 REQ-079, NFR-3(수명)은 REQ-081 로 흡수(dcy-028). +- 보안: 테스트는 TREX_BROKER=paper + in-memory store 로 무중단. live DB 비대상. diff --git a/docs/batches/bat-042_20260615_slot-strategy-state-store/planning.md b/docs/batches/bat-042_20260615_slot-strategy-state-store/planning.md new file mode 100644 index 0000000..6e77921 --- /dev/null +++ b/docs/batches/bat-042_20260615_slot-strategy-state-store/planning.md @@ -0,0 +1,48 @@ +# BAT-042 Planning + +- Status: confirmed +- Batch: BAT-042 +- Profile: standard + +## Included REQ +- REQ-079 (Data) — slot_strategy_state 저장소 + StrategyStateStore + CAS +- REQ-080 (Interface) — trex.slots.strategy_state.set write RPC +- REQ-081 (Data) — occupancy_seq 가드 + close 삭제 불변식 +- REQ-082 (Documentation) — baseline 정합 +- REQ-073 (Interface, CHG-20260615-01) — slots.req 응답 strategy_state·occupancy_seq additive +- REQ-074 (Data, CHG-20260615-01) — 슬롯 행 스키마 strategy_state·occupancy_seq additive + +## Delivery Plan +- 구현 순서(의존 기준): + 1. **M1 — 슬롯 occupancy_seq (REQ-081 일부)**: `Slot.occupancy_seq` 필드 + EXPECTED_SLOT_FIELDS + PG 마이그레이션 SQL + `inactive→pending_buy` bump(service.py). 가장 하부 — 이후 CAS·노출의 선행. + 2. **M2 — strategy-state 저장소 (REQ-079)**: `StrategyStateStore` 포트 + in-memory 구현 + PG 구현 + 마이그레이션(`slot_strategy_state` 테이블) + occupancy_seq·version CAS + opaque UTF-8 + size cap. + 3. **M3 — close 삭제 훅 (REQ-081 나머지)**: `→INACTIVE` 전이 시 store.delete(slot_id) 훅(service.py 단일 지점) + 데몬 와이어링. + 4. **M4 — write RPC (REQ-080)**: topology 큐 + set 핸들러(store 위임) + daemon 와이어링 + reject reason. + 5. **M5 — read piggyback (REQ-073/074 CHG)**: slot_to_dict 에 occupancy_seq + slots 핸들러 strategy_state batched 조립. + 6. **M6 — baseline 문서 (REQ-082)**: runtime-flows/project-structure/interface-contract 정합(코드 확정 후). +- 각 M 마다 테스트 추가(TDD 우선 권장). in-memory store 로 paper/테스트 무중단. + +## Dependencies +- Internal: + - 슬롯 도메인(`models.py`/`service.py`/`state.py`), 슬롯 영속화(`postgresql.py`/in_memory_repository), 메시징(`topology.py`/`messaging_handlers.py`/`daemon.py`), `portfolio.py`(slot_to_dict). + - dcy-020 영구 슬롯 모델(→INACTIVE 전이), REQ-073/074 기존 read 표면. +- External: + - 없음(전략 데몬 trex-tsl 은 별도 저장소 소비자 — 본 batch 는 core 계약만). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 신규 테이블 스키마 + 신규 도메인 필드(occupancy_seq) + 신규 RPC + 기존 read 표면(REQ-073/074) 확장 + CAS 동시성 의미가 얽혀 있어, 구현 전 인터페이스·스키마·전이 훅 경계를 design.md 로 고정해야 drift/재작업을 줄인다. runtime-flows baseline 영향도 있음. + +## Milestones +1. M1 occupancy_seq 필드+bump+마이그레이션 +2. M2 StrategyStateStore + CAS (in-memory+PG) +3. M3 close 삭제 훅 +4. M4 write RPC +5. M5 read piggyback(073/074) +6. M6 baseline 문서 + +## Risks +- 점유 전환 ABA(R-1, dcy-028): occupancy_seq CAS 로 차단 — M1+M2 에서 ABA 테스트(S-4) 필수. +- 기존 read 표면 회귀(REQ-073/074): additive 만 — 기존 키 집합/filter/spy/divergence 회귀 테스트로 보강. +- PG 마이그레이션 2건(occupancy_seq 컬럼 + slot_strategy_state 테이블): idempotent(IF NOT EXISTS) + in-memory 병행으로 paper 무중단. live DB 비대상(테스트는 trex_core_test DSN). +- EXPECTED_SLOT_FIELDS 계약 가드(test_contract): occupancy_seq 추가 시 계약 테스트 동반 갱신 필요. diff --git a/docs/batches/bat-042_20260615_slot-strategy-state-store/verification.md b/docs/batches/bat-042_20260615_slot-strategy-state-store/verification.md new file mode 100644 index 0000000..89c4bdb --- /dev/null +++ b/docs/batches/bat-042_20260615_slot-strategy-state-store/verification.md @@ -0,0 +1,32 @@ +# BAT-042 Verification + +- Status: confirmed +- Batch: BAT-042 + +## Acceptance Mapping +- REQ-079 (저장소+CAS): + - AC-1 first-create / AC-2 version commit·conflict / AC-3 occupancy·inactive reject / AC-4 size cap / AC-5 batched 조회 그룹화 / AC-6 PG·in-memory 동등 → `tests/application/test_strategy_state_store.py`(TestCas, TestBatchedRead, TestDelete) + `test_postgresql_repository.py::test_strategy_state_store_pg`(PG 게이트). +- REQ-080 (write RPC): + - AC-1 큐(declare_topology, rabbitmq) / AC-2 성공 version+1 / AC-3 occupancy·version reject / AC-4 inactive·size·bad_request / AC-5 멱등 / AC-6 reason 집합 → `tests/application/test_strategy_state_set_handler.py`(8 케이스 + reason 집합). +- REQ-081 (occupancy_seq + close 삭제): + - AC-1 계약 가드(EXPECTED_SLOT_FIELDS, `test_contract` green) / AC-2 bump·안정성 / AC-3 모든 close 경로 삭제 / AC-4 재사용 빈 시작 / AC-5 ABA reject → `tests/domain/slot/test_occupancy_seq_and_close_delete.py` + store ABA(`test_strategy_state_store.py::TestAbaGuard`). +- REQ-082 (baseline): + - AC-1 runtime-flows / AC-2 project-structure / AC-3 interface-contract / AC-4 경계 명시 → `docs/runtime-flows.md`(slot strategy-state 흐름 + Change Log), `docs/project-structure.md`(strategy_state.py 책임), `docs/interface-contract.md`(set RPC 계약 + slots.req additive + Change Log). +- REQ-073 (AC-7, CHG): + - 행에 strategy_state 맵(보유/미보유)·occupancy_seq, batched 조립, strategy_* reasoning 부재 → `tests/application/test_slots_handler_strategy_state.py` + 기존 `test_slots_handler.py` 회귀 green. +- REQ-074 (AC-6, CHG): + - 행 strategy_state 맵·occupancy_seq 필드 노출 → `tests/application/test_slots_handler_strategy_state.py`(row 단언). + +## Evidence +- Test: + - `TREX_BROKER=paper uv run pytest -q` → **479 passed, 47 skipped**(PG 게이트 live DSN 없어 skip). 신규 33 테스트(store CAS 6+ABA, occupancy_seq bump/close 삭제 4경로, set RPC 9, slots read 조립 5). + - 성공 기준 매핑: S-1=slots read 조립 테스트, S-2=set 핸들러/스토어 CAS 케이스, S-3=close 삭제 모든 경로, S-4=ABA reject(store+handler). +- Manual: + - 없음(전부 자동 테스트로 커버). 실 PG 경로는 게이트 테스트 작성 + release 운영 검증 위임. +- Logs: + - ruff: `uv run ruff check trex_core/ tests/` → All checks passed. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. (PG 마이그레이션 014/015·PgStrategyStateStore 의 실 DB 검증은 release 운영 단계 — 게이트 테스트는 작성됨.) diff --git a/docs/batches/bat-043_20260615_sell-intent-retryable-flag/implementation.md b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/implementation.md new file mode 100644 index 0000000..d6a1382 --- /dev/null +++ b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/implementation.md @@ -0,0 +1,26 @@ +# BAT-043 Implementation + +- Status: completed +- Batch: BAT-043 + +## Plan Summary +- rejected SellIntent 응답에 additive `retryable: bool` 추가 (core 소유 매핑). 완전 additive·하위호환. + +## Changed Files +- `trex_core/application/messaging_handlers.py` — `_SELL_REJECT_RETRYABLE` dict + `_sell_reject_retryable(reason)` helper(미인식 true, `bad_request*` false) + intent 응답 빌더에 `intent_type=="sell"` & `status=="rejected"` 시 `retryable` 부여(정상 거부 경로 + sell bad_request 조기반환 경로 모두). +- `docs/interface-contract.md` — intent RPC 응답 표에 rejected sell `retryable` 명시 + Change Log. +- `tests/application/test_sell_retryable_flag.py` (신규) — 매핑 7 reason + 미인식 + None + bad_request + 응답 필드(inactive/unknown_slot/bad_request → false, admitted/buy → 부재). + +## Execution Log +1. `_SELL_REJECT_RETRYABLE` 매핑 + helper 추가(messaging_handlers). +2. 응답 빌더 2지점(정상 결정 / sell bad_request)에 additive 부여. +3. interface-contract 정합 + 테스트 작성. + +## Validation +- `TREX_BROKER=paper uv run pytest -q` → **487 passed, 47 skipped**(BAT-042 479 + 본 batch 8). 신규 8: 매핑 3 + 응답 5. +- `uv run ruff check trex_core/ tests/` → All checks passed. +- 회귀: 기존 intent consumer/handler 테스트 green(admitted/buy 응답 retryable 부재 확인 포함). + +## Remaining Risks +- reason 어휘 drift: 새 reason 추가 시 매핑 갱신 필요(미인식 기본 true 안전망 + 매핑 테스트가 현행 reason 전수 커버). +- 없음(완전 additive, 기존 계약 불변). diff --git a/docs/batches/bat-043_20260615_sell-intent-retryable-flag/index.md b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/index.md new file mode 100644 index 0000000..6aaa1b9 --- /dev/null +++ b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/index.md @@ -0,0 +1,31 @@ +# BAT-043: rejected SellIntent `retryable` 플래그 (issue #27) + +- Status: release-candidate +- Profile: batch-lite +- Source Discovery: 없음 (REQ-007 change-req, issue #27) +- Included REQ: REQ-007 (CHG-20260615-01) +- Excluded REQ: 없음 + +## Summary +- rejected SellIntent 응답(`trex.intent.req`, `intent_type:"sell"`, `status:"rejected"`)에 additive `retryable: bool` 을 추가한다. core 가 소유한 reason→retryable 매핑으로 채우고 `reason` 은 유지한다. trex-tsl 데몬이 손절매 재시도 판단을 reason 문자열 하드코딩 대신 권위 플래그로 하게 해 안전 경로 결합을 제거한다(issue #27, #26 sell-idempotency 후속). + +## Scope +- In Scope: + - `trex_core/application/messaging_handlers.py` — intent 응답 빌더에 sell+rejected 시 `retryable` 부여 + core 소유 `_SELL_REJECT_RETRYABLE` 매핑(미인식 기본 true). + - `docs/interface-contract.md` — rejected sell 응답 `retryable` 필드 명시. + - 테스트: reason→retryable 매핑 케이스, 미인식 기본 true, admitted/processing/buy 부재, 기존 reason 유지. +- Out of Scope: + - reason 어휘 자체 변경(매핑만 추가). + - buy intent / admitted / processing 응답 변경. + - dcy-028(strategy-state) — 독립. + +## Documents +- Planning: [planning.md](./planning.md) +- Design: none (batch-lite, additive 단일 필드 — 구조/흐름 영향 없음) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- 완전 additive·하위호환: 필드 부재(older core) → 데몬 reason-string 폴백. 기존 응답/admit/buy 불변이라 회귀 리스크 낮음. +- 안전 기본값: 미인식 reason → `retryable: true`(멈춘 손절매가 hammering 보다 위험, issue #27 답변에서 확정). +- `bad_request:*`(malformed) → false(클라이언트 결함, 같은 바이트 재시도 무의미). diff --git a/docs/batches/bat-043_20260615_sell-intent-retryable-flag/planning.md b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/planning.md new file mode 100644 index 0000000..b8bef38 --- /dev/null +++ b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/planning.md @@ -0,0 +1,29 @@ +# BAT-043 Planning + +- Status: confirmed +- Batch: BAT-043 +- Profile: batch-lite + +## Included REQ +- REQ-007 (CHG-20260615-01) — rejected SellIntent 응답 additive `retryable: bool` + +## Delivery Plan +1. `messaging_handlers.py` 에 core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + helper(`_sell_retryable(reason)` — 미인식 기본 true, `bad_request:*`/terminal → false). +2. intent 응답 빌더: `intent_type=="sell"` 且 `status=="rejected"` 일 때만 `retryable` 부여. bad_request 조기반환 경로(sell 식별 가능 시)도 동일 적용. +3. 테스트 추가 + interface-contract 문서 정합. + +## Dependencies +- Internal: `messaging_handlers.make_intent_rpc_handler`(응답 빌더), `intents.py`(reason 어휘 — _admit_sell/_dedup). +- External: trex-tsl 데몬(소비자, 별도 저장소) — 폴백 보유로 lockstep 불요. + +## Design Gate +- Design Required Before Implementation: no +- Reason: 단일 additive 응답 필드 + 순수 매핑(dict). 구조/런타임 흐름/스키마 영향 없음. 계약 변화는 interface-contract 한 줄. design.md 불요(batch-lite). + +## Milestones +1. 매핑 + helper + 응답 빌더 +2. 테스트 + 문서 + +## Risks +- reason 어휘 drift: 매핑이 core 에 있어 새 reason 추가 시 함께 갱신(미인식 기본 true 가 안전망). — 매핑 테스트로 현행 reason 전수 커버. +- bad_request 경로: sell 여부 판별 가능 지점에서만 retryable=false 부여(불명확하면 미부여 — 데몬 폴백). diff --git a/docs/batches/bat-043_20260615_sell-intent-retryable-flag/verification.md b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/verification.md new file mode 100644 index 0000000..56617a4 --- /dev/null +++ b/docs/batches/bat-043_20260615_sell-intent-retryable-flag/verification.md @@ -0,0 +1,23 @@ +# BAT-043 Verification + +- Status: confirmed +- Batch: BAT-043 + +## Acceptance Mapping +- REQ-007 (AC-7, CHG-20260615-01): + - rejected sell 응답에 additive `retryable` + core 소유 매핑 + `reason` 유지 → `tests/application/test_sell_retryable_flag.py::TestResponseField`(inactive→false, unknown_slot→false, bad_request→false). + - core 소유 매핑(7 reason) + 미인식 기본 true + bad_request false → `TestRetryableMapping`. + - 완전 additive(admitted/processing/buy·기존 필드 불변) → `test_admitted_sell_has_no_retryable`, `test_buy_rejection_has_no_retryable` + 기존 intent consumer 회귀. + +## Evidence +- Test: + - `TREX_BROKER=paper uv run pytest -q` → **487 passed, 47 skipped**. 신규 8(매핑 3 + 응답 5). +- Manual: + - 없음(자동 테스트 커버). +- Logs: + - ruff: All checks passed. + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. diff --git a/docs/batches/index.md b/docs/batches/index.md index f060c50..66b1fa6 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -49,3 +49,5 @@ | BAT-039 | standard | release-candidate | REQ-011 (CHG-20260612-01) | 없음 (issue #4) | [bat-039](./bat-039_20260612_paper-seq-redis-persistence/) | | BAT-040 | standard | release-candidate | REQ-002 (CHG-20260612-01), REQ-006 (CHG-20260612-01), REQ-073 (CHG-20260612-02) | 없음 (issue #25) | [bat-040](./bat-040_20260612_slots-rpc-db-filter/) | | BAT-041 | standard | release-candidate | REQ-076, REQ-077, REQ-078 | dcy-027 (issue #20 이월) | [bat-041](./bat-041_20260612_long-term-instance-profile/) | +| BAT-042 | standard | release-candidate | REQ-079, REQ-080, REQ-081, REQ-082, REQ-073 (CHG-20260615-01), REQ-074 (CHG-20260615-01) | dcy-028 (issue #26) | [bat-042](./bat-042_20260615_slot-strategy-state-store/) | +| BAT-043 | batch-lite | release-candidate | REQ-007 (CHG-20260615-01) | 없음 (issue #27) | [bat-043](./bat-043_20260615_sell-intent-retryable-flag/) | diff --git a/docs/discovery/dcy-028_20260615_slot-strategy-state-store.md b/docs/discovery/dcy-028_20260615_slot-strategy-state-store.md index f08123f..6194eb8 100644 --- a/docs/discovery/dcy-028_20260615_slot-strategy-state-store.md +++ b/docs/discovery/dcy-028_20260615_slot-strategy-state-store.md @@ -5,10 +5,10 @@ - 문서 ID: dcy-028_20260615_slot-strategy-state-store - 이슈명: Slot Strategy-State 저장소 (per-strategy opaque state store on trex-core slots, issue #26 C-1/C-2/C-3) - 작성 시각(KST): 2026-06-15 16:26 -- 마지막 갱신 시각(KST): 2026-06-15 17:52 +- 마지막 갱신 시각(KST): 2026-06-15 18:05 - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: REQ-079, REQ-080, REQ-081, REQ-082 (+ change-req: REQ-073, REQ-074) # 1. 계획 상태 요약 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 867f142..df54d2e 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -64,7 +64,7 @@ | 패턴 | 표면 | 요청 속성 | 응답 | |---|---|---|---| | 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) | -| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. 체결/종료는 응답 아닌 이벤트로 | +| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. **rejected sell** 응답은 additive `retryable: bool` 포함(REQ-007 AC-7/issue #27 — core 소유 reason→retryable, 미인식 기본 true, `bad_request:*` false; admitted/processing/buy 는 미포함). 체결/종료는 응답 아닌 이벤트로 | | update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) | | 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 | @@ -422,6 +422,22 @@ resume_kill_switch 요청·응답 (REQ-046): - pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다. - 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요. +## Interface: `trex.slots.strategy_state.set` RPC (BAT-042 / dcy-028, REQ-080) + +- Type: external-service (strategy daemon ↔ core write RPC) +- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON. +- Purpose: 슬롯의 opaque strategy-state 를 occupancy_seq+version CAS 로 write. read 는 `trex.slots.req` 에 piggyback(별도 RPC 아님). + +### Inputs +- `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등), delivery persistent. `data` 는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB). + +### Outputs / Outcomes +- 성공: `{ok:true, slot_id, strategy_id, version:}`. +- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:`. 소비자는 미인식 reason 에 fail-closed. + +### Compatibility Rules +- read 표면(`trex.slots.req`)의 각 슬롯 행에 additive 로 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{:{version,data,updated_at}}`)이 실린다(REQ-073/074 CHG). 기존 소비자는 unknown 필드 무시로 하위호환. `strategy_state` 는 strategy_* reasoning 필드(strategy_id 등, REQ-074 AC-5 제외 대상)와 구분되는 opaque 상태 맵이다. + ## Shared Constraints - 도메인은 외부 의존(브로커, DB 드라이버 등)을 직접 호출하지 않는다(순수 도메인 경계). @@ -448,6 +464,8 @@ resume_kill_switch 요청·응답 (REQ-046): - 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026). - 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후). - 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065). +- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변). +- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082). - 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`. - 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01). - 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062) diff --git a/docs/project-structure.md b/docs/project-structure.md index b46eacc..7a99e53 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -107,6 +107,8 @@ - `trex_core/domain/slot/` - 자산 중심 영구 슬롯 순수 도메인. 5 상태 머신(`state.py`, inactive 기준 + 인스턴스 스코프 advisory lock id), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol — instance 스코프 list/count/reconcile), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`). - 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨). +- `trex_core/application/strategy_state.py` (BAT-042 / dcy-028) + - slot strategy-state 저장소 경계: `StrategyStateStore` Protocol + `InMemoryStrategyStateStore` + 공용 `_cas`(occupancy_seq+version) + reject reason 상수 + byte size cap. opaque UTF-8 `data`(core 무해석). PG 구현은 `adapters/persistence/postgresql.py`의 `PgStrategyStateStore`(`slot_strategy_state` 테이블, 마이그레이션 014/015). 슬롯 본체에는 `Slot.occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속). - `tests/domain/slot/` - 상태머신/서비스 단위 테스트(`test_state`, `test_service`) + 컬럼 계약 가드(`test_contract`). InMemorySlotRepository 기반으로 실제 DB 없이 검증. - `docs/` diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 2ec17a3..0e45a95 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -300,6 +300,18 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분: 6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다. 7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지). +## Flow: `slot strategy-state 저장소` (BAT-042 / dcy-028, REQ-079~082) + +전략 데몬(예: trex-tsl)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 로 보관한다. core 는 `data` 를 해석하지 않는다(opaque UTF-8 + byte size cap 만 검증). + +1. **read (piggyback, C-1)**: 데몬이 매 사이클 `trex.slots.req` 로 슬롯을 읽으면 각 행에 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{: {version,data,updated_at}}`)이 함께 실린다. 맵은 핸들러가 반환 slot_id 들로 `StrategyStateStore.get_for_slots()` 1회 batched 조회 후 group by 해 조립한다(추가 왕복 없음). 슬롯 현재 occupancy_seq 와 다른 stale 엔트리는 노출하지 않는다. +2. **evaluate (pure)**: 데몬은 행의 `strategy_state[strategy_id]` 를 read-only 입력으로 `evaluate()` 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행). +3. **write (별도 RPC, C-2)**: 상태가 바뀌면 `trex.slots.strategy_state.set` 으로 `{slot_id, strategy_id, expected_version, expected_occupancy_seq, data}` 를 보낸다. core 는 슬롯의 현재 occupancy_seq·active 여부를 조회해 CAS 검증: 점유 불일치 → `occupancy_changed`, version 불일치 → `version_conflict`, inactive/미존재 → `slot_inactive`, size 초과 → `state_too_large`, 일치(없으면 expected_version=0) → commit + version+1. malformed → `bad_request`. 동일 입력 재전송은 무부작용. +4. **occupancy 가드 (C-2b, R-1)**: 슬롯은 monotonic `occupancy_seq` 를 갖고 새 점유 시작(`inactive→pending_buy`)마다 ++. 영구 슬롯 재사용 시 read v0→close→reopen→write v0 의 ABA(옛 점유 계산이 새 점유에 바인딩)를 occupancy_seq CAS 가 구조적으로 차단한다. +5. **close 삭제 (C-3)**: 슬롯이 어떤 경로로든 `→INACTIVE`(전략 매도/safety-sell/forced-close/operator) 되면 `transit_slot` 단일 전이 지점에서 해당 slot_id 의 모든 strategy_state 를 삭제한다. 재사용 슬롯은 새 occupancy_seq + 빈 상태로 시작 — 전략/데몬은 cleanup 책임이 없다(슬롯이 source of truth + GC). + +저장은 별도 `slot_strategy_state` 테이블(복합 PK `(slot_id, strategy_id)`), 슬롯 본체에는 `occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속 Discovery). full 3계층(occupancy 테이블 분할)·다중 writer 동시성·장기 보존은 범위 밖. + ## Flow Constraints - 모든 상태 변경 API 는 advisory-locked 단일 트랜잭션 안에서 수행한다. @@ -329,6 +341,7 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분: - 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026). - 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현). - 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, DB 재초기화 도구(`trex-core-reset`, 데이터 보존 cutover 대체 — REQ-064 CHG-20260610-03) (REQ-056~065). +- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 저장소: opaque per-strategy state(별도 `slot_strategy_state` 테이블), read piggyback(`trex.slots.req` 행에 `strategy_state` 맵+`occupancy_seq`), write 별도 RPC(`trex.slots.strategy_state.set`, occupancy_seq+version CAS), occupancy_seq 점유 가드(점유 전환 ABA 차단), close(`→INACTIVE`) 시 자동 삭제 (REQ-079~082, REQ-073/074 CHG). full 3계층 분할은 후속. ## Flow: `포지션 사이징` (BAT-031 / dcy-021, REQ-066) 보유(holding) 자산 슬롯의 수량을 명령형으로 조정한다. 전략이 스냅샷 cash+quantity 로 qty+price 를 계산해 보내고 core 는 실행만 한다(target 미저장). diff --git a/docs/srs/Data/req-074_merged-slot-row-schema.md b/docs/srs/Data/req-074_merged-slot-row-schema.md index c341add..42f8ecb 100644 --- a/docs/srs/Data/req-074_merged-slot-row-schema.md +++ b/docs/srs/Data/req-074_merged-slot-row-schema.md @@ -37,6 +37,47 @@ ## Change Log +### CHG-20260615-02 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented, BAT-042). 신규 AC-6(strategy_state 맵+occupancy_seq 행 필드) 구현·검증 완료 — `slot_to_dict` occupancy_seq + 핸들러 strategy_state 조립, `tests/application/test_slots_handler_strategy_state.py`(row 단언). 기존 AC-1~5 불변. 479 passed, ruff clean. Implemented 복귀. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- (change-req, dcy-028/issue #26 C-1) 슬롯 행 스키마에 두 additive 필드를 추가한다: **`strategy_state`**(`{: {version,data,updated_at}}` opaque 맵 — 별도 `slot_strategy_state` 테이블(REQ-079)에서 조립, 미보유 시 omit/`{}`)과 **`occupancy_seq`**(int — 슬롯 행 컬럼, REQ-081 점유 가드). 기존 필드(슬롯/자산/워치독/overlay 정본)·divergence·overlay 동작은 불변. AC-5 의 "strategy 계열 필드 없음"은 strategy_* **reasoning** 필드(strategy_id/sell_strategy_id/params/active)를 가리키며, `strategy_state`(opaque 상태 맵)는 그와 구분되는 신규 additive 필드다. + +#### Intent +- 전략 opaque 상태와 점유 식별자를 슬롯 행에 노출해 read piggyback(C-1)과 write CAS 점유 가드(REQ-080)를 지원. + +#### Acceptance Criteria Delta +- Added: + - AC-6(신규): 행에 `strategy_state` 맵(보유 시)과 `occupancy_seq`(int)가 포함된다. `strategy_state` 는 별도 테이블 조립, `occupancy_seq` 는 슬롯 행 컬럼. strategy_* reasoning 필드 부재 유지(AC-5 정합). +- Updated: + - AC-5: 제외되는 "strategy 계열"을 strategy_* reasoning 필드로 한정하고 `strategy_state`/`occupancy_seq` additive 필드는 허용함을 명확화. +- Removed: 없음 + +#### Impacted Area Delta +- Docs: docs/interface-contract.md(strategy_state·occupancy_seq 행 필드 + 정본 라벨 — REQ-082), docs/data-model.md +- Modules: trex_core/application/portfolio.py(slot_to_dict — strategy_state 맵·occupancy_seq 병합) +- Tests: tests/application/(행 strategy_state 맵·occupancy_seq 단언) + +#### Delivery Trace Delta +- Batches: BAT-035(dcy-025) 불변 — 신규 AC-6 은 dcy-028 batch 에서 구현·검증. +- Releases: REL-006(REQ-074 released) — 기존 계약 영향 없음(additive). 신규 필드는 dcy-028 batch. + +#### Revalidation Impact +- Reverification Needed: yes (신규 AC-6 만) +- Existing Implementation Invalidated: no (기존 행 필드/divergence/overlay 보존) +- Status Recommendation: revert-to-approved (Implemented → Approved — 신규 additive AC 구현·검증 대기, 선례 REQ-002/006 CHG-20260612) +- Follow-up Action: confirm-req → dcy-028 batch 에서 REQ-073/079/081 과 함께 구현·검증 → confirm-req-implemented + +#### Validation Plan +- slot_to_dict 테스트: 행에 strategy_state 맵(보유/미보유)·occupancy_seq 포함, 기존 필드/divergence/overlay 회귀. + ### CHG-20260611-01 - Date: 2026-06-11 - Author: Justin Ko diff --git a/docs/srs/Data/req-079_slot-strategy-state-store.md b/docs/srs/Data/req-079_slot-strategy-state-store.md new file mode 100644 index 0000000..6d40ba7 --- /dev/null +++ b/docs/srs/Data/req-079_slot-strategy-state-store.md @@ -0,0 +1,86 @@ +# req-079: Slot strategy-state 저장소 — `slot_strategy_state` 테이블 + StrategyStateStore 포트 + occupancy_seq·version CAS + +- Status: Implemented +- Type: Data +- Priority: High +- Owner: project maintainers + +## Intent +- 전략 데몬(trex-tsl 등)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 영속화할 저장소를 신설한다. trex-core 는 값을 해석하지 않고(opaque) 저장/조회/CAS 만 책임진다. 슬롯이 source of truth 이자 GC 가 되어 데몬은 cleanup 책임이 없다. (dcy-028 FR-2/OQ-1/OQ-4, issue #26 C-2) + +## Requirement +- 별도 테이블 `slot_strategy_state` 를 신설한다(슬롯 행 JSONB 미사용 — dcy-028 OQ-4): 복합 PK `(slot_id, strategy_id)`, 컬럼 `version int`, `data text`, `updated_at timestamptz`, 그리고 점유 식별 `occupancy_seq int`(REQ-081 가드 참조). strategy 당 1행. +- `StrategyStateStore` 포트(Protocol)를 정의하고 PG 구현과 in-memory 구현(복합키 dict)을 함께 제공한다(paper/테스트 무중단). +- CAS upsert 의미: + 1. occupancy_seq 일치 + version 일치 → commit, version+1 반환. + 2. occupancy 불일치 → `occupancy_changed` (저장 안 함). version 불일치 → `version_conflict` (저장 안 함). 슬롯 inactive/미존재 → `slot_inactive` (저장 안 함). + 3. 최초(엔트리 없음 + expected_version=0 + occupancy 일치) → version=1 생성. + 4. 동일 입력(같은 expected_version) 재적용은 부작용 없음(멱등). +- `data` 는 opaque UTF-8 문자열로 저장한다. core 검증은 (1) 유효 UTF-8 + (2) byte 크기 상한뿐 — JSON/base64 를 강제하지 않는다. 상한은 2단(기본 1~2KB / hard ceiling 4KB byte). 초과 시 `state_too_large` 로 저장 거부(REQ-080 RPC 가 reason 운반). +- 조회 API 는 slot_id 집합으로 batched 조회를 지원해 C-1 read(REQ-073/074) 가 추가 왕복 없이 행별 맵으로 조립할 수 있어야 한다. + +## Acceptance Criteria +1. Given 비어 있는 `(slot_id, strategy_id)`, When expected_version=0 + 일치 occupancy_seq 로 set, Then version=1 로 생성되고 같은 키 조회 시 data·version=1 이 반환된다. +2. Given version=N 인 엔트리, When expected_version=N + 일치 occupancy_seq 로 set, Then commit + version=N+1. When expected_version≠N 으로 set, Then `version_conflict` 로 저장되지 않고 기존 값이 유지된다. +3. Given 슬롯의 현재 occupancy_seq=k, When expected_occupancy_seq≠k 로 set, Then `occupancy_changed` 로 저장되지 않는다. Given inactive/미존재 슬롯, When set, Then `slot_inactive` 로 저장되지 않는다. +4. Given 크기 상한 초과 `data`, When set, Then `state_too_large` 로 저장 거부된다. Given 유효 UTF-8 + 상한 이내, Then core 가 내용 해석 없이 그대로 저장한다. +5. Given 여러 slot_id, When batched 조회, Then 각 slot_id 별 `{strategy_id: {version,data,updated_at}}` 맵으로 그룹되어 반환된다(C-1 조립 입력). +6. Given PG 구현과 in-memory 구현, When 동일 시나리오 테스트, Then 두 구현이 동일한 CAS 의미를 보인다. + +## Impacted Area +- Docs: docs/data-model.md(`slot_strategy_state` 스키마), docs/project-structure.md(저장소 구조 — REQ-082) +- Modules: trex_core/adapters/persistence/postgresql.py(PG StrategyStateStore + 마이그레이션 SQL), trex_core/domain/slot/in_memory_repository.py 또는 신규 모듈(in-memory), 신규 포트 정의(예: trex_core/application/ports.py 또는 strategy_state 모듈) +- Tests: tests/adapters/persistence/(PG CAS 게이트), tests/(in-memory CAS 6 케이스 — commit/version_conflict/occupancy_changed/slot_inactive/state_too_large/first-create + 멱등) + +## Notes +- occupancy_seq 컬럼은 REQ-081(슬롯 점유 가드)과 짝을 이룬다 — 본 REQ 는 저장·CAS, REQ-081 은 슬롯 측 occupancy_seq 부여·bump·close 삭제를 소유한다. +- write RPC 계약(요청/응답 shape, reject reason 운반)은 REQ-080 이 소유한다. 본 REQ 는 저장 의미. +- 단일 writer 전제(해당 slot·strategy 데몬 1개). CAS 는 재시도/중복·점유전환 안전용이지 다중 writer 동시성 제어가 아니다. +- 출처: dcy-028 FR-2, OQ-1(인코딩/상한), OQ-4(별도 테이블+맵+slot_id 키), R-2/R-3. + +## Change Log + +### CHG-20260615-03 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-042(release-candidate) verification — `slot_strategy_state` 테이블(마이그 015) + `StrategyStateStore`/`InMemoryStrategyStateStore`/`PgStrategyStateStore` + occupancy_seq·version CAS + opaque UTF-8·size cap. AC-1~6 evidence: `tests/application/test_strategy_state_store.py`(CAS 6 케이스+ABA+batched+delete) + `test_postgresql_repository.py::test_strategy_state_store_pg`(PG 게이트). 479 passed, ruff clean. Blocking Issues 없음. + +### CHG-20260615-02 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Proposed → Approved (confirm-req). 필수 필드·구체 AC(6개) 충족, source dcy-028(confirmed, Open OQ 0건, OQ-1/OQ-4 정합) 일치. blocker 없음. REQ-080/081/082 + REQ-073/074 CHG 와 동일 batch 편성 예정. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- dcy-028 FR-2/OQ-1/OQ-4 로부터 최초 생성(Proposed). slot_strategy_state 저장소 + StrategyStateStore 포트 + occupancy_seq·version CAS + opaque UTF-8 data + 2단 size cap. + +#### Intent +- 전략 opaque 상태를 슬롯 수명에 묶어 server-side 영속화 — 데몬 재시작/장애에도 상태 보존, orphan-cleanup 제거. + +#### Acceptance Criteria Delta +- Added: first-create/version CAS/occupancy CAS/size cap/batched 조회/PG·in-memory 동등 6개 기준 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: data-model.md, project-structure.md(REQ-082) +- Modules: postgresql.py, in-memory store, 신규 포트 +- Tests: PG CAS, in-memory CAS 6 케이스 + +#### 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-080/081/082 + REQ-073/074 CHG 와 동일 batch) + +#### Validation Plan +- in-memory CAS 6 케이스 + PG 게이트 테스트로 저장 의미 검증. batched 조회는 C-1 조립 테스트(REQ-073/074)와 연계. diff --git a/docs/srs/Data/req-081_slot-occupancy-seq-guard-and-close-delete.md b/docs/srs/Data/req-081_slot-occupancy-seq-guard-and-close-delete.md new file mode 100644 index 0000000..b9b5fb0 --- /dev/null +++ b/docs/srs/Data/req-081_slot-occupancy-seq-guard-and-close-delete.md @@ -0,0 +1,81 @@ +# req-081: 슬롯 occupancy_seq 정합성 가드 + close 시 strategy_state 삭제 불변식 + +- Status: Implemented +- Type: Data +- Priority: High +- Owner: project maintainers + +## Intent +- 영구 슬롯이 재사용될 때(점유 전환) 옛 점유가 계산한 strategy_state 가 새 점유에 바인딩되는 ABA 누수를 구조적으로 차단하고, 슬롯 close 시 strategy_state 를 자동 삭제해 데몬 cleanup 책임을 없앤다. occupancy_seq 경량 가드(컬럼 1개)로 full 3계층 분할 없이 정합성 임계부만 선취한다. (dcy-028 FR-2b/FR-3, R-1, issue #26 occupancy race #4705990067) + +## Requirement +- `Slot` 도메인에 monotonic `occupancy_seq:int` 필드를 추가하고 `EXPECTED_SLOT_FIELDS` 계약과 PG 스키마(daily_slot, 레거시 명 — 실제 영구 자산 슬롯)에 마이그레이션으로 반영한다. 기본값 0(또는 점유 없음 표시). +- 새 점유 시작 전이(`inactive → pending_buy`)에서 `occupancy_seq` 를 ++ 한다. 이 값은 한 점유 구간 동안 안정적이고 직전 점유와 달라야 한다(monotonic, 실패한 매수 롤백으로 번호가 건너뛰어도 무방). +- `occupancy_seq` 를 슬롯 행에 노출해 C-1 read(REQ-073/074)·write CAS(REQ-079/080)가 점유 식별에 사용하게 한다. +- 슬롯이 어떤 경로로든 close(`→INACTIVE`: 전략 매도 체결/safety-sell/forced-close/operator)되면 단일 전이 지점(`service.py` INACTIVE 분기, position 필드 clear 와 동일 위치)에서 해당 slot_id 의 모든 strategy_state 를 삭제한다(REQ-079 store). 행 삭제가 아니라 영구 슬롯 `→INACTIVE` 전이 훅이다(dcy-020 정합). +- 재사용 슬롯은 새 occupancy_seq + 빈 strategy_state(version 없음)로 시작한다. + +## Acceptance Criteria +1. Given `Slot` 도메인, When 필드 계약 검사, Then `occupancy_seq` 가 `EXPECTED_SLOT_FIELDS` 에 포함되고 PG daily_slot 에 컬럼이 존재한다. +2. Given inactive 슬롯, When `inactive→pending_buy` 전이, Then `occupancy_seq` 가 직전보다 증가하고 해당 점유 구간(pending_buy→holding→pending_sell) 동안 동일 값으로 유지된다. +3. Given holding 슬롯의 strategy_state 존재, When 슬롯이 임의 close 경로로 `→INACTIVE`, Then 해당 slot_id 의 모든 strategy_state 가 삭제된다(전략 매도/safety-sell/forced-close 모두 커버). +4. Given close 후 재매수된 슬롯, When 조회, Then 새 occupancy_seq + 빈 strategy_state(version 없음)로 시작한다. +5. Given 점유 전환 ABA 시나리오(옛 점유 기준 occupancy_seq 로 write), When set, Then REQ-080 가 `occupancy_changed` 로 reject 해 stale 상태가 새 점유에 바인딩되지 않는다(R-1 차단). + +## Impacted Area +- Docs: docs/data-model.md(occupancy_seq 필드), docs/runtime-flows.md(점유 가드·close 삭제 흐름 — REQ-082) +- Modules: trex_core/domain/slot/models.py(`occupancy_seq` 필드 + EXPECTED_SLOT_FIELDS), trex_core/domain/slot/service.py(inactive→pending_buy bump + →INACTIVE strategy_state 삭제 훅), trex_core/adapters/persistence/postgresql.py(컬럼 + 마이그레이션 SQL), trex_core/application/portfolio.py(slot_to_dict occupancy_seq 노출) +- Tests: tests/domain/slot/(occupancy_seq bump·계약 가드), tests/(각 close 경로 후 strategy_state 부재 + ABA reject S-4) + +## Notes +- 저장·CAS 의미는 REQ-079, write RPC 는 REQ-080 이 소유한다 — 본 REQ 는 슬롯 측 occupancy_seq 부여·bump 와 close 삭제 훅(lifecycle)을 소유한다. +- full 3계층(별도 occupancy 테이블 + strategy_state 를 occupancy_id 키로 전환)은 dcy-028 Out of Scope — 후속 "영구 슬롯 구조 정합" Discovery. 본 REQ 는 컬럼 1개 경량 가드. +- 중장기 영구 슬롯은 점유 회전이 드물어 full 분할이 과하고, intraday(trex-tsl Mode A) 정합성은 본 가드로 충족(dcy-028 OQ-4). +- 출처: dcy-028 FR-2b(C-2b)/FR-3(C-3), R-1. + +## Change Log + +### CHG-20260615-03 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-042 verification — `Slot.occupancy_seq`(+EXPECTED_SLOT_FIELDS, 마이그 014) + `inactive→pending_buy` bump + `→INACTIVE` strategy_state delete 훅(service). AC-1~5 evidence: `tests/domain/slot/test_occupancy_seq_and_close_delete.py`(bump/안정성/모든 close 경로 삭제/재사용 빈 시작) + `test_strategy_state_store.py::TestAbaGuard`(S-4 ABA reject) + `test_contract`(계약 가드). 479 passed, ruff clean. Blocking Issues 없음. + +### CHG-20260615-02 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Proposed → Approved (confirm-req). 필수 필드·구체 AC(5개) 충족, source dcy-028(FR-2b/FR-3, R-1 완화) 정합. occupancy_seq 경량 가드로 점유 ABA 구조적 차단. blocker 없음. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- dcy-028 FR-2b/FR-3 로부터 최초 생성(Proposed). occupancy_seq 슬롯 필드+bump+노출 + close 시 strategy_state 삭제 불변식. + +#### Intent +- 점유 전환 ABA 누수(R-1) 구조적 차단 + close cleanup 자동화. + +#### Acceptance Criteria Delta +- Added: 필드 계약/bump 안정성/close 삭제 모든 경로/재사용 빈 시작/ABA reject 5개 기준 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: data-model.md, runtime-flows.md(REQ-082) +- Modules: models.py, service.py, postgresql.py, portfolio.py +- Tests: occupancy_seq bump·계약, close 경로 삭제, ABA reject + +#### 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-079/080/082 + REQ-073/074 CHG 동일 batch) + +#### Validation Plan +- occupancy_seq bump·계약 가드 테스트 + 각 close 경로 후 strategy_state 부재 테스트 + ABA reject 테스트(S-4)로 검증. diff --git a/docs/srs/Documentation/req-082_strategy-state-baseline-alignment.md b/docs/srs/Documentation/req-082_strategy-state-baseline-alignment.md new file mode 100644 index 0000000..b339f13 --- /dev/null +++ b/docs/srs/Documentation/req-082_strategy-state-baseline-alignment.md @@ -0,0 +1,77 @@ +# req-082: strategy-state baseline 문서 정합 — read/write/삭제 흐름 + occupancy_seq 가드 + 저장소 구조 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: project maintainers + +## Intent +- Slot strategy-state 저장소의 구조·흐름·계약 경계를 baseline 문서에 명시해 이후 REQ/batch·전략 데몬·운영자가 참조 가능하게 한다. 특히 read piggyback / write 별도 RPC 비대칭, occupancy_seq 점유 가드, close 삭제 불변식, opaque/size cap 경계를 문서로 확정한다. (dcy-028 FR-4) + +## Requirement +- `docs/runtime-flows.md` 에 strategy-state 흐름을 추가한다: 데몬 decision-cycle(`trex.slots.req` read piggyback → `evaluate()` → 변경 시 `trex.slots.strategy_state.set` write), occupancy_seq·version CAS, 점유 전환 가드(`occupancy_changed`), 슬롯 close(`→INACTIVE`) 시 strategy_state 삭제 훅. +- `docs/project-structure.md` 에 저장소 구조를 반영한다: `slot_strategy_state` 테이블(복합 PK), `StrategyStateStore` 포트 + PG/in-memory 구현 위치, slot `occupancy_seq` 컬럼. +- `docs/interface-contract.md` 에 `trex.slots.strategy_state.set` 계약(요청/응답 shape, reject reason 집합)과 slots.req 응답의 strategy_state 맵·occupancy_seq 필드(C-1)를 명시한다. +- 경계 명시: trex-core 는 `data` 를 해석하지 않는다(opaque), schema 는 전략 소유, size cap(기본 1~2KB / ceiling 4KB byte), 단일 writer 전제, full 3계층 분할은 후속 Discovery. + +## Acceptance Criteria +1. Given `docs/runtime-flows.md`, When strategy-state 섹션 확인, Then read piggyback/write 별도 RPC·occupancy_seq 가드·close 삭제 흐름이 현행 코드(REQ-079/080/081)와 정합하게 기술되어 있다. +2. Given `docs/project-structure.md`, When 확인, Then `slot_strategy_state` 테이블·StrategyStateStore·occupancy_seq 컬럼 위치가 반영되어 있다. +3. Given `docs/interface-contract.md`, When 확인, Then strategy_state.set 계약(요청/응답/reject reason 집합)과 slots.req 행의 strategy_state·occupancy_seq 필드가 명시되어 있다. +4. Given 신규 전략 개발자, When baseline 문서만으로 strategy-state 사용 모델 파악 시도, Then opaque 경계·size cap·점유 가드·삭제 책임(core)이 모호함 없이 판단 가능하다. + +## Impacted Area +- Docs: docs/runtime-flows.md(strategy-state 흐름 + Change Log), docs/project-structure.md(저장소 구조), docs/interface-contract.md(계약), docs/data-model.md(스키마 참조 — REQ-079/081 과 정합) +- Modules: 없음(문서 전용) +- Tests: 없음(문서 diff 리뷰로 검증) + +## Notes +- REQ-079/080/081 구현 결과와 같은 batch 에서 정합 — 코드 확정 후 문서화(선례: REQ-075/078 등 baseline 정합 REQ). +- 출처: dcy-028 FR-4, S-1~S-4 의 문서 참조 가능성. + +## Change Log + +### CHG-20260615-03 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-042 verification — `docs/runtime-flows.md`(slot strategy-state 흐름 + Change Log), `docs/project-structure.md`(strategy_state.py 책임), `docs/interface-contract.md`(strategy_state.set 계약 + slots.req additive + Change Log) 반영, 현행 코드 정합. AC-1~4 충족(흐름/구조/계약/경계 명시). Blocking Issues 없음. + +### CHG-20260615-02 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Proposed → Approved (confirm-req). 필수 필드·구체 AC(4개) 충족, source dcy-028(FR-4) 정합. 코드 확정 후 문서화(REQ-079/080/081 과 동일 batch). blocker 없음. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- dcy-028 FR-4 로부터 최초 생성(Proposed). strategy-state read/write/삭제 흐름·occupancy_seq 가드·저장소 구조·계약 baseline 정합. + +#### Intent +- strategy-state 모델의 구조·흐름·경계를 문서로 확정해 운영·후속 개발·전략 데몬 참조점 제공. + +#### Acceptance Criteria Delta +- Added: runtime-flows/project-structure/interface-contract 정합 + 개발자 판단 가능성 4개 기준 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: runtime-flows.md, project-structure.md, interface-contract.md, data-model.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: confirm-req 후 batch 편성(REQ-079/080/081 + REQ-073/074 CHG 와 동일 batch, 코드 확정 후 문서화) + +#### Validation Plan +- baseline 문서 diff 리뷰 — strategy-state 흐름·구조·계약 섹션 존재 + 현행 코드 정합 확인. diff --git a/docs/srs/Interface/req-007_intent-submission-api.md b/docs/srs/Interface/req-007_intent-submission-api.md index 0906f37..26b978f 100644 --- a/docs/srs/Interface/req-007_intent-submission-api.md +++ b/docs/srs/Interface/req-007_intent-submission-api.md @@ -18,6 +18,7 @@ 4. Given 동일 슬롯에 대한 동시 intent, When 병렬 처리, Then advisory lock 으로 직렬화되어 단일 writer 불변식이 유지된다(매수·매도 전략은 슬롯을 직접 쓰지 않음). (NFR-2) 5. Given 수락된 intent, When `GET /v1/intents/{intent_id}`, Then 기록된 decision 을 반환한다. 6. (CHG-20260610-02, dcy-023/issue #22) Given buy intent payload, Then `allocated_capital` 은 계약 필드가 아니다 — `BuyIntent` 에 필드가 없고 admission 경로에서 전달되지 않으며, 외부 발신자가 payload 에 키를 포함해도 unknown key 로 무시된다(즉시 제거, dcy-023 OQ-1). +7. (CHG-20260615-01, issue #27) Given **rejected** sell intent 응답(`intent_type:"sell"`, `status:"rejected"`), Then additive `retryable: bool` 필드가 포함된다 — core 가 소유한 reason→retryable 매핑으로 채워지고 `reason` 은 유지된다. 매핑: `slot_state_changed`/`slot_not_holding:pending_buy`/`duplicate_in_flight` → `true`; `slot_not_found`/`slot_not_holding:inactive`/`slot_not_holding:pending_sell`/`slot_not_holding:error`/`bad_request:*` → `false`; 미인식 reason → `true`(안전 기본 — 멈춘 손절매가 hammering 보다 위험). admitted/processing/buy 응답·기존 필드는 불변(완전 additive·하위호환). ## Impacted Area - Docs: docs/interface-contract.md(HTTP API 계약), docs/runtime-flows.md(요청 흐름) — REQ-009 에서 정합 @@ -401,3 +402,42 @@ - Author: Justin Ko - Type: status - Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- (change-req, issue #27) rejected SellIntent 응답에 additive `retryable: bool` 추가. core 소유 reason→retryable 매핑으로 채우고 `reason` 유지. #26 sell-idempotency 논의(거부 sticky)의 후속 — 데몬이 reason 문자열 하드코딩 없이 권위 플래그로 retry/stop 판단(손절매 안전 경로 decoupling). + +#### Intent +- 거부 retryability를 core 도메인 지식으로 캡슐화해, 새 reason 추가 시 데몬 분류가 조용히 drift하는 안전 실패 모드 제거(issue #27). + +#### Acceptance Criteria Delta +- Added: AC-7 (rejected sell 응답 additive `retryable` + core 소유 매핑 + 미인식 기본 true + 완전 additive) +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: docs/interface-contract.md(rejected sell 응답 `retryable` 필드) +- Modules: trex_core/application/messaging_handlers.py(intent 응답 빌더 — sell+rejected 시 retryable; core 소유 `_SELL_REJECT_RETRYABLE` 매핑) +- Tests: tests/application/(retryable 플래그 매핑 케이스 + additive 하위호환) + +#### Delivery Trace Delta +- Batches: BAT-043(편성 예정) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: yes (신규 AC-7만) +- Existing Implementation Invalidated: no (완전 additive — 기존 응답 필드/admit/buy 불변) +- Status Recommendation: revert-to-approved (Implemented → Approved — 신규 additive AC 구현·검증 대기) +- Follow-up Action: BAT-043 batch-lite 구현·검증 후 confirm-req-implemented + +#### Validation Plan +- messaging_handlers 테스트: 각 reject reason → 기대 retryable 매핑, 미인식 reason → true, admitted/processing/buy 응답에 retryable 부재, 기존 reason 필드 유지. + +### CHG-20260615-IMPL +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-043(release-candidate) verification — rejected sell 응답 additive `retryable`(core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + `_sell_reject_retryable` helper, 미인식 true/`bad_request*` false). AC-7 evidence: `tests/application/test_sell_retryable_flag.py`(매핑 7 reason+미인식+None+bad_request, 응답 inactive/unknown_slot/bad_request→false, admitted/buy 부재). 487 passed, ruff clean. 기존 AC-1~6 불변. Blocking Issues 없음. diff --git a/docs/srs/Interface/req-073_slots-req-rpc-contract.md b/docs/srs/Interface/req-073_slots-req-rpc-contract.md index 9faa0ab..5ecbcf6 100644 --- a/docs/srs/Interface/req-073_slots-req-rpc-contract.md +++ b/docs/srs/Interface/req-073_slots-req-rpc-contract.md @@ -37,6 +37,56 @@ ## Change Log +### CHG-20260615-02 + +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented, BAT-042). 신규 AC-7(strategy_state 맵+occupancy_seq additive, batched 조립) 구현·검증 완료 — `tests/application/test_slots_handler_strategy_state.py` + 기존 `test_slots_handler.py` 회귀 green. 기존 AC-1~6 불변. 479 passed, ruff clean. Implemented 복귀. + +### CHG-20260615-01 + +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-028/issue #26 C-1) `trex.slots.req` 응답의 각 슬롯 행에 **strategy_state 맵**(`{: {version,data,updated_at}}`, REQ-079/074)과 **occupancy_seq**(int, REQ-081)를 additive 로 추가한다. 핸들러는 반환 slot_id 들로 `StrategyStateStore`(REQ-079) batched 조회 후 group by 해 행별 맵으로 조립한다(추가 왕복 없음). 미보유 시 omit/`{}`. 기존 envelope 키(`slots`/`available_slots`/`degraded`/`reason`/`message`)·filter·spy·degraded 동작은 불변. AC-3 의 "strategy 계열 키 없음"은 dcy-025 가 제외한 strategy_* **reasoning** 필드(strategy_id/params/active)를 가리키며, 본 변경의 `strategy_state`(opaque 상태 맵)·`occupancy_seq`는 그와 구분되는 신규 additive 필드다. + +#### Intent + +- 전략 데몬이 슬롯과 함께 opaque strategy-state 를 추가 왕복 없이 read(C-1 piggyback). write 는 별도 RPC(REQ-080). + +#### Acceptance Criteria Delta + +- Added: + - AC-7(신규): 행에 `strategy_state` 맵(보유 시)과 `occupancy_seq` 가 포함되고, batched 조회로 조립되어 추가 RPC 왕복이 없다. strategy_* reasoning 필드 부재는 유지(AC-3 정합). +- Updated: + - AC-3: 응답 키 집합에서 제외되는 "strategy 계열"은 strategy_* reasoning 필드로 한정하고 `strategy_state`/`occupancy_seq` additive 필드는 허용함을 명확화. +- Removed: 없음 + +#### Impacted Area Delta + +- Docs: docs/interface-contract.md(strategy_state·occupancy_seq 행 필드 — REQ-082) +- Modules: trex_core/application/messaging_handlers.py(slots 핸들러 strategy_state batched 조립), trex_core/application/portfolio.py(slot_to_dict) +- Tests: tests/application/(strategy_state 맵·occupancy_seq 행 조립, batched 조회) + +#### Delivery Trace Delta + +- Batches: BAT-035(dcy-025, 기존 Implemented 근거) 불변 — 신규 AC-7 은 dcy-028 batch 에서 구현·검증. +- Releases: REL-006(REQ-073 released) — 기존 계약 영향 없음(additive). 신규 필드는 REL-007 이후 dcy-028 batch. + +#### Revalidation Impact + +- Reverification Needed: yes (신규 AC-7 만) +- Existing Implementation Invalidated: no (기존 envelope/filter/spy 동작 보존) +- Status Recommendation: revert-to-approved (Implemented → Approved — 신규 additive AC 구현·검증 대기, 선례 REQ-002/006 CHG-20260612) +- Follow-up Action: confirm-req → dcy-028 batch 에서 REQ-079/080/081 과 함께 구현·검증 → confirm-req-implemented + +#### Validation Plan + +- slots 핸들러 테스트: 행에 strategy_state 맵(보유/미보유)·occupancy_seq 포함, batched 조회로 추가 왕복 없음, 기존 키 집합/filter/spy/degraded 회귀. + ### CHG-20260612-02 - Date: 2026-06-12 diff --git a/docs/srs/Interface/req-080_strategy-state-set-rpc.md b/docs/srs/Interface/req-080_strategy-state-set-rpc.md new file mode 100644 index 0000000..6903c85 --- /dev/null +++ b/docs/srs/Interface/req-080_strategy-state-set-rpc.md @@ -0,0 +1,87 @@ +# req-080: `trex.slots.strategy_state.set` write RPC 계약 — occupancy_seq·version CAS + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: project maintainers + +## Intent +- 전략 데몬이 슬롯의 opaque strategy-state 를 write 하는 신규 RPC 계약을 정의한다. read 는 기존 `trex.slots.req`(REQ-073) 에 piggyback 하고(추가 왕복 없음), write 만 별도 RPC 로 분리해 `evaluate()` 순수성과 재시도 격리를 보존한다. (dcy-028 FR-2/OQ-2, issue #26 C-2) + +## Requirement +- 신규 RPC `trex.slots.strategy_state.set` 를 기존 계약 패턴(direct reply-to + JSON + per-instance `queue_name({base}.{instance_key})`)으로 신설한다. +- 요청 payload: `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등 키), delivery persistent. +- 성공 응답: `{ok:true, slot_id, strategy_id, version:}`. +- 실패 응답 reason 전체 집합 = `{occupancy_changed, version_conflict, slot_inactive, state_too_large, bad_request}`: + - `occupancy_changed`: 슬롯 현재 occupancy_seq ≠ expected_occupancy_seq → `{ok:false, reason, occupancy_seq:}` (저장 안 함; 데몬 re-read 후 fresh restart). + - `version_conflict`: stored version ≠ expected_version → `{ok:false, reason, version:}`. + - `slot_inactive`: 슬롯 inactive/미존재 → `{ok:false, reason}` (저장 안 함). + - `state_too_large`: `data` 가 size cap(REQ-079/NFR) 초과 → `{ok:false, reason}` (저장 안 함). + - `bad_request:`: malformed payload(기존 intent 핸들러 패턴) → `{ok:false, reason}`. +- 핸들러는 REQ-079 `StrategyStateStore` 의 CAS 로 위임한다(저장 의미는 REQ-079 소유). `data` 는 opaque 로 전달하고 핸들러는 해석하지 않는다. +- 동일 `correlation_id` 재전송은 부작용 없음(멱등). 미인식 reason 에 대해 소비자 fail-closed 를 계약에 명시한다. + +## Acceptance Criteria +1. Given 기동된 데몬, When 토폴로지 선언, Then `trex.slots.strategy_state.set`(per-instance) 큐가 존재하고 direct reply-to 로 응답한다. +2. Given 유효 요청(occupancy_seq·version 일치), When set, Then `{ok:true, version:<+1>}` 를 반환하고 저장된다. +3. Given occupancy_seq 불일치, Then `{ok:false, reason:"occupancy_changed", occupancy_seq:}`. Given version 불일치, Then `{ok:false, reason:"version_conflict", version:}`. 둘 다 저장되지 않는다. +4. Given inactive 슬롯, Then `{ok:false, reason:"slot_inactive"}`. Given size cap 초과, Then `{ok:false, reason:"state_too_large"}`. Given malformed payload, Then `{ok:false, reason:"bad_request:"}`. +5. Given 동일 `correlation_id` 재전송, When 처리, Then 부작용 없이 동일 결과가 반환된다. +6. Given 응답 reason, When 집합 검사, Then `{occupancy_changed, version_conflict, slot_inactive, state_too_large, bad_request}` 외 값이 없다. + +## Impacted Area +- Docs: docs/interface-contract.md(strategy_state.set 계약 — REQ-082), docs/runtime-flows.md(write 흐름 — REQ-082) +- Modules: trex_core/adapters/messaging/topology.py(큐명), trex_core/application/messaging_handlers.py(신규 set 핸들러 — REQ-079 store 위임), trex_core/runtime/daemon.py(핸들러 와이어링) +- Tests: tests/application/(set 핸들러 6 reason 케이스 + 멱등), tests/adapters/messaging/(토폴로지) + +## Notes +- 저장·CAS 의미는 REQ-079 가 소유한다 — 본 REQ 는 RPC 계약(요청/응답 shape, reason 운반, 멱등, 큐). 핸들러는 store 로 위임만 한다. +- read piggyback(REQ-073/074)과의 비대칭(read 무료/write 분리)은 dcy-028 OQ-2 의도. 사이클당 다중 슬롯 write 시 N개 set RPC — optional batch 변형은 v1 범위 밖 후속. +- 출처: dcy-028 FR-2(C-2), OQ-2(별도 RPC), reject reason 집합(trex-tsl #4705990067 답변 정합). + +## Change Log + +### CHG-20260615-03 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-042 verification — `trex.slots.strategy_state.set` 큐(topology/rabbitmq declare) + `make_strategy_state_set_handler`(store 위임) + daemon consumer 스레드. AC-1~6 evidence: `tests/application/test_strategy_state_set_handler.py`(성공+5 reason+bad_request 2종+멱등+reason 집합). 479 passed, ruff clean. Blocking Issues 없음. + +### CHG-20260615-02 +- Date: 2026-06-15 +- Author: Justin Ko +- Type: status +- Summary: Proposed → Approved (confirm-req). 필수 필드·구체 AC(6개) 충족, source dcy-028(OQ-2 정합) 일치, reject reason 집합 trex-tsl #4705990067 답변과 정합. blocker 없음. + +### CHG-20260615-01 +- Date: 2026-06-15 +- Author: Justin Ko + +#### Change Summary +- dcy-028 FR-2/OQ-2 로부터 최초 생성(Proposed). strategy_state.set write RPC 계약 + occupancy_seq·version CAS + reject reason 전체 집합. + +#### Intent +- write 를 별도 RPC 로 분리해 evaluate 순수성·재시도 격리 보존, read 는 slots.req piggyback. + +#### Acceptance Criteria Delta +- Added: 큐/성공/occupancy·version reject/inactive·size·bad_request/멱등/reason 집합 6개 기준 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: interface-contract.md, runtime-flows.md(REQ-082) +- Modules: topology.py, messaging_handlers.py, daemon.py +- Tests: set 핸들러 reason 케이스, 토폴로지 + +#### 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-079/081/082 + REQ-073/074 CHG 동일 batch) + +#### Validation Plan +- set 핸들러 단위 테스트(6 reason 케이스 + 멱등) + 토폴로지 선언 테스트로 RPC 계약 검증. diff --git a/docs/srs/index.md b/docs/srs/index.md index 97de52a..8ff7c48 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-079` +- Current: `REQ-083` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -159,9 +159,19 @@ | REQ-076 | watchdog timeout per-instance TOML 설정 | Configuration | Implemented | High | project maintainers | [req-076](./Configuration/req-076_watchdog-timeout-per-instance-toml.md) | | REQ-077 | 중장기 인스턴스 운영 프로파일 — 표준 TOML 레시피 + 기동 검증 | Configuration | Implemented | High | project maintainers | [req-077](./Configuration/req-077_long-term-instance-operation-profile.md) | | REQ-078 | 중장기 운영 baseline 문서 정합 — 보호 규칙 경계 + forced_close_at 미사용 | Documentation | Implemented | Medium | project maintainers | [req-078](./Documentation/req-078_long-term-operation-baseline-alignment.md) | +| REQ-079 | Slot strategy-state 저장소 — slot_strategy_state 테이블 + StrategyStateStore + occupancy_seq·version CAS | Data | Implemented | High | project maintainers | [req-079](./Data/req-079_slot-strategy-state-store.md) | +| REQ-080 | trex.slots.strategy_state.set write RPC 계약 — occupancy_seq·version CAS | Interface | Implemented | High | project maintainers | [req-080](./Interface/req-080_strategy-state-set-rpc.md) | +| REQ-081 | 슬롯 occupancy_seq 정합성 가드 + close 시 strategy_state 삭제 불변식 | Data | Implemented | High | project maintainers | [req-081](./Data/req-081_slot-occupancy-seq-guard-and-close-delete.md) | +| REQ-082 | strategy-state baseline 문서 정합 — read/write/삭제 흐름 + occupancy_seq 가드 | Documentation | Implemented | Medium | project maintainers | [req-082](./Documentation/req-082_strategy-state-baseline-alignment.md) | ## Recent Change Log Summary +- 2026-06-15 :: REQ-007 Approved → **Implemented** (confirm-req-implemented, BAT-043 release-candidate, issue #27): rejected SellIntent 응답 additive `retryable` 구현·검증 완료 — core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + helper, 487 passed(+8), ruff clean. issue #27 close. +- 2026-06-15 :: REQ-007 change-req (CHG-20260615-01, issue #27): rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true). #26 sell-idempotency 후속 — 데몬 손절매 재시도 안전 경로 decoupling. 완전 additive(기존 불변) → 신규 AC-7 구현·검증 대기 → Implemented → **Approved** 되돌림. +- 2026-06-15 :: REQ-079·080·081·082·073·074 Approved → **Implemented** (confirm-req-implemented, BAT-042 release-candidate, dcy-028): slot strategy-state 저장소 구현·검증 완료 — `slot_strategy_state` 테이블+StrategyStateStore(in-memory+PG)+occupancy_seq·version CAS(REQ-079), write RPC `trex.slots.strategy_state.set`(REQ-080), occupancy_seq 가드+close 삭제(REQ-081), baseline 정합(REQ-082), slots.req 행 strategy_state·occupancy_seq additive(REQ-073 AC-7/REQ-074 AC-6). 479 passed (+33 신규: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5), ruff clean. PG 경로 게이트 테스트 작성(실 DB 검증 release 위임). Blocking Issues 없음. +- 2026-06-15 :: REQ-079·080·081·082 Proposed → **Approved** (confirm-req): 필수 필드·구체 AC 충족, source dcy-028(confirmed, Open OQ 0) 정합. REQ-073/074(Approved, CHG) 와 함께 dcy-028 단일 batch 편성 예정. +- 2026-06-15 :: REQ-073·074 change-req (CHG-20260615-01, dcy-028/issue #26 C-1): slots.req 응답 행에 `strategy_state` 맵(별도 테이블 조립)·`occupancy_seq` additive 추가. 기존 envelope/필드/divergence/overlay 동작 보존, strategy_* reasoning 필드 부재 유지(strategy_state 와 구분). 신규 AC(073-AC7/074-AC6) 구현·검증 대기 → Implemented → **Approved** 되돌림(선례 REQ-002/006 CHG-20260612). +- 2026-06-15 :: REQ-079·080·081·082 신규 생성(Proposed, draft-req dcy-028): Slot strategy-state 저장소 — REQ-079 저장소+CAS(slot_strategy_state 테이블+StrategyStateStore+opaque UTF-8+2단 size cap), REQ-080 write RPC 계약(trex.slots.strategy_state.set, reject reason 집합), REQ-081 occupancy_seq 가드+close 삭제 불변식(점유 ABA 차단), REQ-082 baseline 정합. read piggyback(REQ-073/074 CHG)·C-4(issue #26 답변)는 별도. NFR-1(opaque) FR 흡수, NFR-2(size cap) REQ-079, NFR-3(수명) REQ-081 흡수. - 2026-06-12 :: REQ-076·077·078 Approved → **Implemented** (confirm-req-implemented, BAT-041, dcy-027): 중장기 인스턴스 운영 구현·검증 완료 — watchdog per-instance TOML(단일 지점 override, env 폴백), `config/longterm.example.toml` 프로파일, runtime-flows 중장기 flow(보호 경계·forced_close_at 미사용). 446 passed (+7), ruff clean. 실 데몬 장기 run 은 release 운영 검증 위임. Blocking Issues 없음. - 2026-06-12 :: REQ-076·077·078 Proposed → **Approved** (confirm-req): 필수 필드·구체 AC 충족, source dcy-027(confirmed, 결정 잔여 0건) 정합. REQ-076/077 동일 batch 편성 예정. - 2026-06-12 :: REQ-076·077·078 신규 생성(Proposed, draft-req dcy-027): 중장기 전략 슬롯 운영 — watchdog timeout per-instance TOML(env 폴백), 중장기 운영 프로파일(TOML 레시피+기동·멀티데이·격리 검증), baseline 정합(보호 규칙 전략 전적 책임 + forced_close_at 미사용 명시). NFR-1 은 batch 검증, NFR-2 는 REQ-077 AC, NFR-3 은 REQ-076 제약으로 흡수. diff --git a/tests/adapters/persistence/test_postgresql_repository.py b/tests/adapters/persistence/test_postgresql_repository.py index 2378431..ad9f2fd 100644 --- a/tests/adapters/persistence/test_postgresql_repository.py +++ b/tests/adapters/persistence/test_postgresql_repository.py @@ -130,3 +130,47 @@ def test_dispatch_queue_instance_scoped(pg_clean): repo_a.mark_dispatched(slot_a.slot_id, "ord-1") assert [s.slot_id for s in repo_a.list_dispatched_pending("inst-a")] == [slot_a.slot_id] assert repo_a.list_dispatched_pending("inst-b") == [] + + +# dcy-028 / REQ-079·081 (BAT-042): strategy-state store + occupancy_seq column +def test_strategy_state_store_pg(pg_clean): + from trex_core.adapters.persistence.postgresql import PgStrategyStateStore + from trex_core.application import strategy_state as ss + + repo = PostgreSQLSlotRepository(pg_clean) + store = PgStrategyStateStore(pg_clean) + svc = SlotService(repo, instance_key=INST, strategy_state_store=store) + repo.reconcile_universe(INST, {"pf-a": "005930"}) + slot = repo.find_slot_by_portfolio(INST, "pf-a") + svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="t", qty=10, avg_price=100) + # occupancy_seq persisted + held = repo.fetch_slot(slot.slot_id) + assert held.occupancy_seq == 1 + + # first-create + r1 = store.set(slot.slot_id, "tsl_v1", expected_version=0, + expected_occupancy_seq=1, data="peak", current_occupancy_seq=1, + slot_active=True) + assert r1.ok and r1.version == 1 + # version conflict + r2 = store.set(slot.slot_id, "tsl_v1", expected_version=0, + expected_occupancy_seq=1, data="stale", current_occupancy_seq=1, + slot_active=True) + assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT + # batched read + got = store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) + assert got[slot.slot_id]["tsl_v1"].data == "peak" + + # close deletes state (C-3) + svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {} + # reuse bumps occupancy_seq + reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + assert reused.occupancy_seq == 2 diff --git a/tests/application/test_sell_retryable_flag.py b/tests/application/test_sell_retryable_flag.py new file mode 100644 index 0000000..39a2894 --- /dev/null +++ b/tests/application/test_sell_retryable_flag.py @@ -0,0 +1,103 @@ +"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01). + +Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable +via the core-owned table (unrecognized → True, safety default; bad_request → False). +admitted / processing / buy responses must NOT carry the field. +""" +from __future__ import annotations + +from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore +from trex_core.application.intents import IntentService +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import ( + _sell_reject_retryable, + make_intent_rpc_handler, +) +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +INST = "default" + + +def _wire(): + repo = InMemorySlotRepository() + svc = SlotService(repo, instance_key=INST) + intents = IntentService(svc, repo, InMemoryIntentStore(), + resolve_symbol=lambda pid: "005930", + instance_key=INST, max_active_slots=5) + repo.reconcile_universe(INST, {"PF-1": "SYM"}) + handler = make_intent_rpc_handler(intents) + slot = repo.list_slots(INST)[0] + return repo, svc, intents, handler, slot + + +def _sell(handler, slot_id, intent_id="s1"): + return handler(RpcRequest(payload={ + "intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id), + }, correlation_id=intent_id)) + + +class TestRetryableMapping: + def test_each_reason_maps(self): + assert _sell_reject_retryable("slot_state_changed") is True + assert _sell_reject_retryable("slot_not_holding:pending_buy") is True + assert _sell_reject_retryable("duplicate_in_flight") is True + assert _sell_reject_retryable("slot_not_found") is False + assert _sell_reject_retryable("slot_not_holding:inactive") is False + assert _sell_reject_retryable("slot_not_holding:pending_sell") is False + assert _sell_reject_retryable("slot_not_holding:error") is False + + def test_unrecognized_defaults_true(self): + assert _sell_reject_retryable("some_future_reason") is True + assert _sell_reject_retryable(None) is True + + def test_bad_request_is_false(self): + assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False + + +class TestResponseField: + def test_rejected_sell_inactive_slot_has_retryable_false(self): + # slot is inactive (never bought) → slot_not_holding:inactive → terminal + repo, svc, intents, handler, slot = _wire() + resp = _sell(handler, slot.slot_id) + assert resp.payload["status"] == "rejected" + assert resp.payload["reason"] == "slot_not_holding:inactive" + assert resp.payload["retryable"] is False + + def test_rejected_sell_unknown_slot_false(self): + from uuid import uuid4 + _, _, _, handler, _ = _wire() + resp = _sell(handler, uuid4()) + assert resp.payload["status"] == "rejected" + assert resp.payload["reason"] == "slot_not_found" + assert resp.payload["retryable"] is False + + def test_bad_request_sell_carries_retryable_false(self): + _, _, _, handler, _ = _wire() + resp = handler(RpcRequest(payload={ + "intent_type": "sell", "intent_id": "x", # missing slot_id + }, correlation_id="x")) + assert resp.payload["status"] == "rejected" + assert resp.payload["reason"].startswith("bad_request") + assert resp.payload["retryable"] is False + + def test_admitted_sell_has_no_retryable(self): + repo, svc, intents, handler, slot = _wire() + # bring slot to holding so the sell is admitted + svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="t", qty=10, avg_price=100) + resp = _sell(handler, slot.slot_id) + assert resp.payload["status"] == "admitted" + assert "retryable" not in resp.payload + + def test_buy_rejection_has_no_retryable(self): + # buy on a non-existent slot → rejected, but buy responses omit retryable + from uuid import uuid4 + _, _, _, handler, _ = _wire() + resp = handler(RpcRequest(payload={ + "intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()), + }, correlation_id="b1")) + assert resp.payload["status"] == "rejected" + assert "retryable" not in resp.payload diff --git a/tests/application/test_slots_handler_strategy_state.py b/tests/application/test_slots_handler_strategy_state.py new file mode 100644 index 0000000..302b06e --- /dev/null +++ b/tests/application/test_slots_handler_strategy_state.py @@ -0,0 +1,83 @@ +"""slots.req read piggyback of strategy_state + occupancy_seq (dcy-028 C-1). + +REQ-073 AC-7 / REQ-074 AC-6 / S-1: each slot row carries occupancy_seq and, +when present, a strategy_state map assembled from the store in one batched read. +strategy_* reasoning fields remain absent. +""" +from __future__ import annotations + +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_slots_rpc_handler +from trex_core.application.strategy_state import InMemoryStrategyStateStore +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +INST = "default" + + +def _wire(): + repo = InMemorySlotRepository() + store = InMemoryStrategyStateStore() + service = SlotService(repo, instance_key=INST, strategy_state_store=store) + repo.reconcile_universe(INST, {"pf-0": "SYM"}) + slot = repo.list_slots(INST)[0] + service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="t", qty=10, avg_price=100) + handler = make_slots_rpc_handler(repo, instance_key=INST, + strategy_state_store=store) + return repo, store, service, slot, handler + + +def _rows(handler): + return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"] + + +def test_row_carries_occupancy_seq(): + _, _, _, slot, handler = _wire() + row = _rows(handler)[0] + assert row["occupancy_seq"] == 1 + + +def test_row_carries_strategy_state_when_present(): + _, store, _, slot, handler = _wire() + store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1, + data='{"peak":100}', current_occupancy_seq=1, slot_active=True) + row = _rows(handler)[0] + assert "strategy_state" in row + assert row["strategy_state"]["tsl_v1"]["version"] == 1 + assert row["strategy_state"]["tsl_v1"]["data"] == '{"peak":100}' + assert "updated_at" in row["strategy_state"]["tsl_v1"] + + +def test_row_omits_strategy_state_when_absent(): + _, _, _, _, handler = _wire() + row = _rows(handler)[0] + assert "strategy_state" not in row + + +def test_no_strategy_reasoning_fields(): + _, store, _, slot, handler = _wire() + store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1, + data="x", current_occupancy_seq=1, slot_active=True) + row = _rows(handler)[0] + for k in ("strategy_id", "sell_strategy_id", "strategy_params", "active_strategy_id"): + assert k not in row + + +def test_reused_slot_shows_no_state(): + """After a full close + reuse cycle, no prior-occupancy state surfaces + (close-delete + read-side occupancy filter). occupancy_seq advances to 2.""" + repo, store, service, slot, handler = _wire() + store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1, + data="old", current_occupancy_seq=1, slot_active=True) + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") # occupancy_seq -> 2 + row = _rows(handler)[0] + assert row["occupancy_seq"] == 2 + assert "strategy_state" not in row diff --git a/tests/application/test_strategy_state_set_handler.py b/tests/application/test_strategy_state_set_handler.py new file mode 100644 index 0000000..7cd794c --- /dev/null +++ b/tests/application/test_strategy_state_set_handler.py @@ -0,0 +1,120 @@ +"""strategy_state.set write RPC handler tests (dcy-028 / REQ-080, BAT-042). + +Covers the full reject reason set + success + bad_request, with the handler +supplying the slot's current occupancy_seq / active-ness to the store CAS. +""" +from __future__ import annotations + +from trex_core.application import strategy_state as ss +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_strategy_state_set_handler +from trex_core.application.strategy_state import InMemoryStrategyStateStore +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +INST = "default" + + +def _wire(): + repo = InMemorySlotRepository() + store = InMemoryStrategyStateStore() + service = SlotService(repo, instance_key=INST, strategy_state_store=store) + repo.reconcile_universe(INST, {"pf-0": "SYM"}) + slot = repo.list_slots(INST)[0] + # activate to holding (occupancy_seq -> 1) + service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="t", qty=10, avg_price=100) + handler = make_strategy_state_set_handler(repo, store) + return repo, store, service, slot, handler + + +def _req(slot_id, *, ev=0, eo=1, data="peak", strat="tsl_v1"): + return RpcRequest(payload={ + "slot_id": str(slot_id), "strategy_id": strat, + "expected_version": ev, "expected_occupancy_seq": eo, "data": data, + }, correlation_id=f"{slot_id}:{strat}:{ev}") + + +def test_first_write_ok(): + _, _, _, slot, handler = _wire() + resp = handler(_req(slot.slot_id, ev=0)) + assert resp.payload["ok"] is True and resp.payload["version"] == 1 + + +def test_version_conflict(): + _, _, _, slot, handler = _wire() + handler(_req(slot.slot_id, ev=0)) # version -> 1 + resp = handler(_req(slot.slot_id, ev=0, data="stale")) + assert resp.payload["ok"] is False + assert resp.payload["reason"] == ss.REASON_VERSION_CONFLICT + assert resp.payload["version"] == 1 + + +def test_occupancy_changed(): + _, _, _, slot, handler = _wire() + # daemon read occupancy 0 (before activation) but slot is occupancy 1 now + resp = handler(_req(slot.slot_id, ev=0, eo=0)) + assert resp.payload["ok"] is False + assert resp.payload["reason"] == ss.REASON_OCCUPANCY_CHANGED + assert resp.payload["occupancy_seq"] == 1 + + +def test_slot_inactive(): + repo, store, service, slot, handler = _wire() + # close the slot + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + resp = handler(_req(slot.slot_id, ev=0, eo=1)) + assert resp.payload["ok"] is False + assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE + + +def test_unknown_slot_is_slot_inactive(): + from uuid import uuid4 + _, _, _, _, handler = _wire() + resp = handler(_req(uuid4(), ev=0, eo=1)) + assert resp.payload["ok"] is False + assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE + + +def test_state_too_large(): + _, _, _, slot, handler = _wire() + big = "a" * (ss._STATE_CAP_BYTES + 1) + resp = handler(_req(slot.slot_id, ev=0, data=big)) + assert resp.payload["ok"] is False + assert resp.payload["reason"] == ss.REASON_STATE_TOO_LARGE + + +def test_bad_request_missing_field(): + _, _, _, slot, handler = _wire() + resp = handler(RpcRequest(payload={"slot_id": str(slot.slot_id)}, + correlation_id="c")) + assert resp.payload["ok"] is False + assert resp.payload["reason"].startswith("bad_request:") + + +def test_bad_request_non_string_data(): + _, _, _, slot, handler = _wire() + resp = handler(RpcRequest(payload={ + "slot_id": str(slot.slot_id), "strategy_id": "tsl_v1", + "expected_version": 0, "expected_occupancy_seq": 1, "data": 123, + }, correlation_id="c")) + assert resp.payload["ok"] is False + assert resp.payload["reason"].startswith("bad_request:") + + +def test_reason_set_is_enumerated(): + """All non-ok reasons must be within the documented set (REQ-080 AC-6).""" + allowed_prefixes = { + ss.REASON_OCCUPANCY_CHANGED, ss.REASON_VERSION_CONFLICT, + ss.REASON_SLOT_INACTIVE, ss.REASON_STATE_TOO_LARGE, "bad_request", + } + _, _, _, slot, handler = _wire() + handler(_req(slot.slot_id, ev=0)) + bad = handler(_req(slot.slot_id, ev=0, data="x")) # version_conflict + reason = bad.payload["reason"].split(":")[0] + assert reason in allowed_prefixes diff --git a/tests/application/test_strategy_state_store.py b/tests/application/test_strategy_state_store.py new file mode 100644 index 0000000..2b6963a --- /dev/null +++ b/tests/application/test_strategy_state_store.py @@ -0,0 +1,118 @@ +"""InMemoryStrategyStateStore CAS tests (dcy-028 / REQ-079, BAT-042). + +Covers the 6 CAS outcomes (occupancy+version commit / version_conflict / +occupancy_changed / slot_inactive / state_too_large / first-create), idempotent +re-send, batched read with stale-occupancy filtering, and delete-on-close. +""" +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from trex_core.application import strategy_state as ss +from trex_core.application.strategy_state import InMemoryStrategyStateStore + + +@pytest.fixture +def store(): + return InMemoryStrategyStateStore() + + +def _set(store, slot_id, *, ev=0, eo=1, data="x", cur_occ=1, active=True, strat="tsl_v1"): + return store.set( + slot_id, strat, + expected_version=ev, expected_occupancy_seq=eo, data=data, + current_occupancy_seq=cur_occ, slot_active=active, + ) + + +class TestCas: + def test_first_create(self, store): + sid = uuid4() + r = _set(store, sid, ev=0, eo=1, cur_occ=1, data='{"peak":100}') + assert r.ok and r.version == 1 + + def test_commit_increments_version(self, store): + sid = uuid4() + _set(store, sid, ev=0) + r = _set(store, sid, ev=1, data="next") + assert r.ok and r.version == 2 + + def test_version_conflict_does_not_store(self, store): + sid = uuid4() + _set(store, sid, ev=0, data="first") # version=1 + r = _set(store, sid, ev=0, data="stale") # expects 0, stored 1 + assert not r.ok and r.reason == ss.REASON_VERSION_CONFLICT and r.version == 1 + # original preserved + got = store.get_for_slots([sid], {sid: 1})[sid]["tsl_v1"] + assert got.data == "first" and got.version == 1 + + def test_occupancy_changed_does_not_store(self, store): + sid = uuid4() + # daemon read occupancy_seq=1 but slot is now occupancy 2 + r = _set(store, sid, ev=0, eo=1, cur_occ=2) + assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED and r.occupancy_seq == 2 + assert store.get_for_slots([sid], {sid: 2}) == {} + + def test_slot_inactive_does_not_store(self, store): + sid = uuid4() + r = _set(store, sid, ev=0, active=False) + assert not r.ok and r.reason == ss.REASON_SLOT_INACTIVE + assert store.get_for_slots([sid], {sid: 1}) == {} + + def test_state_too_large(self, store, monkeypatch): + sid = uuid4() + big = "a" * (ss._STATE_CAP_BYTES + 1) + r = _set(store, sid, ev=0, data=big) + assert not r.ok and r.reason == ss.REASON_STATE_TOO_LARGE + + def test_idempotent_resend_is_conflict_not_double_apply(self, store): + sid = uuid4() + r1 = _set(store, sid, ev=0, data="v1") # -> version 1 + assert r1.ok and r1.version == 1 + # re-send identical expected_version=0 -> rejected (already advanced) + r2 = _set(store, sid, ev=0, data="v1") + assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT + + +class TestAbaGuard: + def test_turnover_first_write_rejected(self, store): + """dcy-028 R-1 / S-4: read v0 of occupancy 1, slot reused as occupancy 2, + stale write against occupancy 1 must not bind to occupancy 2.""" + sid = uuid4() + # occupancy 2 is current; daemon computed against occupancy 1 (eo=1) + r = _set(store, sid, ev=0, eo=1, cur_occ=2, data="stale-peak") + assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED + # new occupancy starts clean + assert store.get_for_slots([sid], {sid: 2}) == {} + + +class TestBatchedRead: + def test_get_for_slots_groups_by_slot_and_strategy(self, store): + a, b = uuid4(), uuid4() + _set(store, a, ev=0, cur_occ=1, strat="tsl_v1", data="A") + _set(store, a, ev=0, cur_occ=1, strat="buy_v1", data="A2") + _set(store, b, ev=0, cur_occ=1, strat="tsl_v1", data="B") + out = store.get_for_slots([a, b], {a: 1, b: 1}) + assert set(out[a]) == {"tsl_v1", "buy_v1"} + assert out[a]["tsl_v1"].data == "A" + assert out[b]["tsl_v1"].data == "B" + + def test_stale_occupancy_entries_invisible(self, store): + sid = uuid4() + _set(store, sid, ev=0, cur_occ=1, data="old") # stored at occupancy 1 + # slot is now occupancy 2 -> the occupancy-1 entry must not surface + assert store.get_for_slots([sid], {sid: 2}) == {} + + def test_empty_slot_ids(self, store): + assert store.get_for_slots([], {}) == {} + + +class TestDelete: + def test_delete_all_removes_every_strategy(self, store): + sid = uuid4() + _set(store, sid, ev=0, strat="tsl_v1") + _set(store, sid, ev=0, strat="buy_v1") + store.delete_all(sid) + assert store.get_for_slots([sid], {sid: 1}) == {} diff --git a/tests/domain/slot/test_occupancy_seq_and_close_delete.py b/tests/domain/slot/test_occupancy_seq_and_close_delete.py new file mode 100644 index 0000000..9be2b0f --- /dev/null +++ b/tests/domain/slot/test_occupancy_seq_and_close_delete.py @@ -0,0 +1,120 @@ +"""Slot occupancy_seq bump + close-delete hook (dcy-028 / REQ-081, BAT-042). + +S-3: every close path (→INACTIVE) deletes the slot's strategy_state and a reused +slot starts with a new occupancy_seq + empty state. occupancy_seq is stable +across one holding episode and bumps on inactive→pending_buy. +""" +from __future__ import annotations + +from trex_core.application.strategy_state import InMemoryStrategyStateStore +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +INST = "default" + + +def _seed(repo): + repo.reconcile_universe(INST, {"pf-0": "SYM"}) + return repo.list_slots(INST)[0] + + +def _service(repo, store=None): + return SlotService(repo, instance_key=INST, strategy_state_store=store) + + +def _activate_to_holding(service, slot_id): + service.transit_slot(slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + return service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="filled", actor="t", qty=10, avg_price=100) + + +class TestOccupancySeq: + def test_bumps_on_inactive_to_pending_buy(self): + repo = InMemorySlotRepository() + service = _service(repo) + slot = _seed(repo) + assert slot.occupancy_seq == 0 + updated = service.transit_slot( + slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="t") + assert updated.occupancy_seq == 1 + + def test_stable_across_holding_episode(self): + repo = InMemorySlotRepository() + service = _service(repo) + slot = _seed(repo) + held = _activate_to_holding(service, slot.slot_id) + assert held.occupancy_seq == 1 + sold = service.transit_slot(slot.slot_id, SlotState.HOLDING, + SlotState.PENDING_SELL, reason="sell", actor="t") + assert sold.occupancy_seq == 1 # unchanged within the episode + + def test_increments_on_reuse(self): + repo = InMemorySlotRepository() + service = _service(repo) + slot = _seed(repo) + _activate_to_holding(service, slot.slot_id) + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + # reuse: occupancy_seq bumps from 1 to 2 + reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE, + SlotState.PENDING_BUY, reason="buy", actor="t") + assert reused.occupancy_seq == 2 + + +class TestCloseDelete: + def _setup(self): + repo = InMemorySlotRepository() + store = InMemoryStrategyStateStore() + service = _service(repo, store) + slot = _seed(repo) + _activate_to_holding(service, slot.slot_id) + # daemon writes state during holding (occupancy_seq=1) + store.set(slot.slot_id, "tsl_v1", expected_version=0, + expected_occupancy_seq=1, data="peak", current_occupancy_seq=1, + slot_active=True) + assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) + return repo, store, service, slot + + def test_sell_fill_close_deletes_state(self): + repo, store, service, slot = self._setup() + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {} + + def test_reused_slot_starts_empty_with_new_occupancy(self): + repo, store, service, slot = self._setup() + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, + reason="sold", actor="t") + reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE, + SlotState.PENDING_BUY, reason="buy", actor="t") + assert reused.occupancy_seq == 2 + assert store.get_for_slots([slot.slot_id], {slot.slot_id: 2}) == {} + + def test_error_close_path_also_deletes(self): + repo, store, service, slot = self._setup() + # holding → error → inactive (operator resolve) also clears state + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.ERROR, + reason="err", actor="t") + service.transit_slot(slot.slot_id, SlotState.ERROR, SlotState.INACTIVE, + reason="resolve", actor="t") + assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {} + + def test_no_store_injected_is_noop(self): + # backward-compat: service without store does not raise on close + repo = InMemorySlotRepository() + service = _service(repo, store=None) + slot = _seed(repo) + _activate_to_holding(service, slot.slot_id) + service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell", actor="t") + out = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, + SlotState.INACTIVE, reason="sold", actor="t") + assert out.status is SlotState.INACTIVE diff --git a/trex_core/adapters/messaging/rabbitmq.py b/trex_core/adapters/messaging/rabbitmq.py index b4b7087..1626e51 100644 --- a/trex_core/adapters/messaging/rabbitmq.py +++ b/trex_core/adapters/messaging/rabbitmq.py @@ -41,6 +41,7 @@ from trex_core.adapters.messaging.topology import ( direct_routing_key, intent_queue, slots_queue, + strategy_state_set_queue, ) from trex_core.application.messaging import ( EventHandler, @@ -140,7 +141,11 @@ class RabbitMQTransport: iq = intent_queue(instance_key) sq = slots_queue(instance_key) cq = control_queue(instance_key) - for q, channel_type in ((iq, "intent"), (sq, "slots"), (cq, "control")): + ssq = strategy_state_set_queue(instance_key) # dcy-028 / REQ-080 + for q, channel_type in ( + (iq, "intent"), (sq, "slots"), (cq, "control"), + (ssq, "strategy_state_set"), + ): ch.queue_declare(q, durable=True, arguments=dlx) # Directed binding: trex.direct → {instance_key}.{channel_type} ch.queue_bind(q, DIRECT_EXCHANGE, diff --git a/trex_core/adapters/messaging/topology.py b/trex_core/adapters/messaging/topology.py index 94c1f61..83e807d 100644 --- a/trex_core/adapters/messaging/topology.py +++ b/trex_core/adapters/messaging/topology.py @@ -14,6 +14,7 @@ from __future__ import annotations # --- RPC queues (durable for intent; transient-ish for slots but kept # durable for simplicity since the queue itself is shared) --- SLOTS_REQ_QUEUE = "trex.slots.req" # REQ-073 / dcy-025: replaces snapshot.req +STRATEGY_STATE_SET_QUEUE = "trex.slots.strategy_state.set" # dcy-028 / REQ-080 INTENT_REQ_QUEUE = "trex.intent.req" PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 (legacy alias of control) CONTROL_REQ_QUEUE = "trex.control.req" # BAT-012 / REQ-043 operator control channel @@ -44,6 +45,10 @@ def slots_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: return _instance_queue(SLOTS_REQ_QUEUE, instance_key) +def strategy_state_set_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: + return _instance_queue(STRATEGY_STATE_SET_QUEUE, instance_key) + + def control_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str: return _instance_queue(CONTROL_REQ_QUEUE, instance_key) diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index 9983fb8..fc5f05a 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -24,6 +24,11 @@ from trex_core.application.ledger_outbox import ( ) from trex_core.application.ledger_port import LedgerTrade from trex_core.application.ports import IntentRecord +from trex_core.application.strategy_state import ( + SetResult, + StrategyStateEntry, + _cas, +) from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord _SLOT_COLUMNS = ( @@ -31,7 +36,7 @@ _SLOT_COLUMNS = ( "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, instance_key, retired_at, " - "timeout_sec, slippage_pct" + "timeout_sec, slippage_pct, occupancy_seq" ) @@ -197,7 +202,7 @@ class PostgreSQLSlotRepository: "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " "forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, " "instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, " - "updated_at=%s WHERE slot_id=%s", + "occupancy_seq=%s, updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, slot.qty, slot.avg_price, slot.limit_price, slot.order_id, @@ -205,7 +210,7 @@ class PostgreSQLSlotRepository: slot.sell_strategy_id, slot.forced_close_at, slot.stop_loss_price, slot.take_profit_price, slot.instance_key, slot.retired_at, - slot.timeout_sec, slot.slippage_pct, + slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), ) @@ -299,6 +304,7 @@ class PostgreSQLSlotRepository: # forced_close_at(11) stop_loss_price(12) take_profit_price(13) # created_at(14) updated_at(15) instance_key(16) retired_at(17) # timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068] + # occupancy_seq(20) [dcy-028/REQ-081] return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), status=SlotState(row[1]), @@ -320,6 +326,7 @@ class PostgreSQLSlotRepository: retired_at=row[17], timeout_sec=int(row[18]) if row[18] is not None else None, slippage_pct=f(row[19]), + occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0, ) @@ -515,3 +522,114 @@ class PostgreSQLLedgerOutbox: updated_at=row[15], next_retry_at=row[16], ) + + +class PgStrategyStateStore: + """slot_strategy_state-backed StrategyStateStore (dcy-028 / REQ-079). + + Stores opaque per-strategy state keyed by (slot_id, strategy_id). The CAS + decision is the same `_cas` used by the in-memory double; occupancy_seq and + slot_active are supplied by the caller (fetched from the slot) so the store + only owns the slot_strategy_state table. + """ + + def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None: + self._psycopg = _require_psycopg(psycopg_module) + self._dsn = dsn + + def set( + self, + slot_id: UUID, + strategy_id: str, + *, + expected_occupancy_seq: int, + expected_version: int, + data: str, + current_occupancy_seq: int, + slot_active: bool, + ) -> SetResult: + with self._psycopg.connect(self._dsn, autocommit=False) as conn: + try: + with conn.cursor() as cur: + cur.execute( + "SELECT occupancy_seq, version, data, updated_at " + "FROM slot_strategy_state " + "WHERE slot_id = %s AND strategy_id = %s FOR UPDATE", + (slot_id, strategy_id), + ) + row = cur.fetchone() + existing = ( + StrategyStateEntry( + strategy_id=strategy_id, + occupancy_seq=int(row[0]), + version=int(row[1]), + data=row[2], + updated_at=row[3], + ) + if row is not None + else None + ) + result = _cas( + existing, + expected_occupancy_seq=expected_occupancy_seq, + expected_version=expected_version, + data=data, + current_occupancy_seq=current_occupancy_seq, + slot_active=slot_active, + ) + if not result.ok: + conn.rollback() + return result + cur.execute( + "INSERT INTO slot_strategy_state " + "(slot_id, strategy_id, occupancy_seq, version, data, updated_at) " + "VALUES (%s, %s, %s, %s, %s, %s) " + "ON CONFLICT (slot_id, strategy_id) DO UPDATE SET " + "occupancy_seq = EXCLUDED.occupancy_seq, " + "version = EXCLUDED.version, data = EXCLUDED.data, " + "updated_at = EXCLUDED.updated_at", + ( + slot_id, strategy_id, current_occupancy_seq, + result.version, data, datetime.now(tz=timezone.utc), + ), + ) + conn.commit() + return result + except BaseException: + conn.rollback() + raise + + def get_for_slots( + self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int] + ) -> dict[UUID, dict[str, StrategyStateEntry]]: + if not slot_ids: + return {} + out: dict[UUID, dict[str, StrategyStateEntry]] = {} + with self._psycopg.connect(self._dsn) as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT slot_id, strategy_id, occupancy_seq, version, data, updated_at " + "FROM slot_strategy_state WHERE slot_id = ANY(%s)", + (list(slot_ids),), + ) + for row in cur.fetchall(): + sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0])) + # stale-occupancy entries are invisible (dcy-028 FR-3 defense) + if int(row[2]) != current_occupancy_seq.get(sid): + continue + out.setdefault(sid, {})[row[1]] = StrategyStateEntry( + strategy_id=row[1], + occupancy_seq=int(row[2]), + version=int(row[3]), + data=row[4], + updated_at=row[5], + ) + return out + + def delete_all(self, slot_id: UUID) -> None: + with self._psycopg.connect(self._dsn, autocommit=False) as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,) + ) + conn.commit() diff --git a/trex_core/adapters/persistence/sql/014_add_slot_occupancy_seq.sql b/trex_core/adapters/persistence/sql/014_add_slot_occupancy_seq.sql new file mode 100644 index 0000000..5e909e1 --- /dev/null +++ b/trex_core/adapters/persistence/sql/014_add_slot_occupancy_seq.sql @@ -0,0 +1,9 @@ +-- BAT-042 / dcy-028 / REQ-081: per-occupancy guard counter on the slot. +-- +-- The permanent asset slot is reused across occupancies; both a closed and a +-- freshly re-entered occupancy of the same slot start strategy_state at version +-- 0, so a version-only CAS cannot distinguish them at the first write. A +-- monotonic occupancy_seq (bumped on inactive→pending_buy) lets the +-- strategy-state write CAS reject a write computed against a prior occupancy +-- (occupancy_changed). Additive, idempotent. BIGINT so it never wraps. +ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS occupancy_seq BIGINT NOT NULL DEFAULT 0; diff --git a/trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql b/trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql new file mode 100644 index 0000000..d6904de --- /dev/null +++ b/trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql @@ -0,0 +1,18 @@ +-- BAT-042 / dcy-028 / REQ-079: opaque per-strategy state store on slots. +-- +-- A strategy daemon accumulates opaque state across a slot's occupancy (e.g. a +-- TSL peak). trex-core stores it server-side keyed by (slot_id, strategy_id), +-- never interpreting `data` (opaque UTF-8, byte-capped at the app layer). The +-- state's lifecycle matches the occupancy: trex-core deletes it on close +-- (→INACTIVE) so a reused slot starts empty (dcy-028 C-3). occupancy_seq mirrors +-- the slot's value at write time for the CAS occupancy guard (dcy-028 R-1). +-- version is the compare-and-set counter (idempotent / retry-safe writes). +CREATE TABLE IF NOT EXISTS slot_strategy_state ( + slot_id UUID NOT NULL, + strategy_id TEXT NOT NULL, + occupancy_seq BIGINT NOT NULL, + version BIGINT NOT NULL, + data TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + PRIMARY KEY (slot_id, strategy_id) +); diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index d339c00..4c10132 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -29,6 +29,33 @@ from trex_core.domain.slot import ( _log = logging.getLogger(__name__) +# ---- sell-reject retryability (issue #27 / REQ-007 CHG-20260615-01) --------- +# core-owned reason -> retryable mapping for the rejected SellIntent response. +# Maintained with the reject vocabulary (intents.py _admit_sell/_dedup) so a new +# reason gets a correct flag without the daemon touching a table. Unrecognized +# reasons default to retryable=True: a stranded stop-loss (held losing position) +# is worse than bounded, observable hammering on a terminal decision. +_SELL_REJECT_RETRYABLE: dict[str, bool] = { + "slot_state_changed": True, + "slot_not_holding:pending_buy": True, + "duplicate_in_flight": True, + "slot_not_found": False, + "slot_not_holding:inactive": False, + "slot_not_holding:pending_sell": False, + "slot_not_holding:error": False, +} + + +def _sell_reject_retryable(reason: Optional[str]) -> bool: + """Map a rejected-sell reason to retryable (issue #27). Default True (safety).""" + if reason is None: + return True + # malformed payload: client defect — retrying the same bytes is pointless. + if reason.startswith("bad_request"): + return False + return _SELL_REJECT_RETRYABLE.get(reason, True) + + # ---- intent RPC handler ----------------------------------------------------- def make_intent_rpc_handler( @@ -112,16 +139,24 @@ def make_intent_rpc_handler( "slot_id": None, "reason": f"unknown_intent_type:{intent_type}", }) except (KeyError, ValueError) as exc: - return RpcResponse(payload={ + payload = { "intent_id": intent_id, "status": "rejected", "slot_id": None, "reason": f"bad_request:{exc}", - }) - return RpcResponse(payload={ + } + # issue #27: sell-side malformed payload carries retryable=False. + if intent_type == "sell": + payload["retryable"] = _sell_reject_retryable(payload["reason"]) + return RpcResponse(payload=payload) + payload = { "intent_id": d.intent_id, "status": d.status, "slot_id": (str(d.slot_id) if d.slot_id else None), "reason": d.reason, - }) + } + # issue #27 / REQ-007 AC-7: additive retryable on rejected sell only. + if intent_type == "sell" and d.status == "rejected": + payload["retryable"] = _sell_reject_retryable(d.reason) + return RpcResponse(payload=payload) return handler @@ -189,6 +224,7 @@ def make_slots_rpc_handler( watchdog_timeout_sec: int = 120, ledger_outbox=None, max_active_slots: Optional[int] = None, + strategy_state_store=None, ): """Build the RPC handler for `trex.slots.req` (REQ-073/074). @@ -236,6 +272,25 @@ def make_slots_rpc_handler( for s in slots ] + # dcy-028 / REQ-073/074·079 (C-1): attach opaque strategy_state map per + # row via one batched store read (read piggyback — no extra round trip). + # Only entries matching the slot's current occupancy_seq are surfaced. + if strategy_state_store is not None and slots: + slot_ids = [s.slot_id for s in slots] + occ = {s.slot_id: getattr(s, "occupancy_seq", 0) for s in slots} + state_map = strategy_state_store.get_for_slots(slot_ids, occ) + for row, s in zip(slot_rows, slots): + entries = state_map.get(s.slot_id) + if entries: + row["strategy_state"] = { + strat: { + "version": e.version, + "data": e.data, + "updated_at": e.updated_at.isoformat(), + } + for strat, e in entries.items() + } + # dcy-018 / REQ-029: cash-estimated overlay (best-effort) if ledger_outbox is not None: try: @@ -277,6 +332,75 @@ def make_slots_rpc_handler( return handler +# ---- strategy-state write RPC handler (dcy-028 / REQ-080) ------------------- + +def make_strategy_state_set_handler( + slot_repo: SlotRepository, + strategy_state_store, +): + """Build the RPC handler for `trex.slots.strategy_state.set` (REQ-080). + + Request: {slot_id, strategy_id, expected_version:int, + expected_occupancy_seq:int, data:str} + Response (ok): {ok:true, slot_id, strategy_id, version:int} + Response (fail): {ok:false, slot_id, strategy_id, reason, version?|occupancy_seq?} + + reason ∈ {occupancy_changed, version_conflict, slot_inactive, + state_too_large, bad_request:}. The store owns the CAS + (REQ-079); the handler supplies the slot's current occupancy_seq and + active-ness and never interprets `data` (opaque). + """ + _ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"} + + def handler(req: RpcRequest) -> RpcResponse: + body = req.payload or {} + try: + slot_id = UUID(str(body["slot_id"])) + strategy_id = str(body["strategy_id"]) + expected_version = int(body["expected_version"]) + expected_occupancy_seq = int(body["expected_occupancy_seq"]) + data = body["data"] + if not isinstance(data, str): + raise ValueError("data must be a string") + except (KeyError, ValueError, TypeError) as exc: + return RpcResponse(payload={ + "ok": False, + "slot_id": body.get("slot_id"), + "strategy_id": body.get("strategy_id"), + "reason": f"bad_request:{exc}", + }) + + slot = slot_repo.fetch_slot(slot_id) + slot_active = slot is not None and slot.status.value in _ACTIVE_STATES + current_occupancy_seq = getattr(slot, "occupancy_seq", 0) if slot else 0 + + result = strategy_state_store.set( + slot_id, + strategy_id, + expected_occupancy_seq=expected_occupancy_seq, + expected_version=expected_version, + data=data, + current_occupancy_seq=current_occupancy_seq, + slot_active=slot_active, + ) + payload = { + "ok": result.ok, + "slot_id": str(slot_id), + "strategy_id": strategy_id, + } + if result.ok: + payload["version"] = result.version + else: + payload["reason"] = result.reason + if result.version is not None: + payload["version"] = result.version + if result.occupancy_seq is not None: + payload["occupancy_seq"] = result.occupancy_seq + return RpcResponse(payload=payload) + + return handler + + # ---- pause/resume RPC handler (REQ-032 AC-4) -------------------------------- def make_pause_rpc_handler(pause_store: PauseStore): diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index 1ec63c3..b472eb9 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -272,6 +272,9 @@ def slot_to_dict( "portfolio_id": s.portfolio_id, "forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None, "universe_detached": getattr(s, "retired_at", None) is not None, + # dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy + # daemon echoes this as expected_occupancy_seq on strategy_state writes. + "occupancy_seq": getattr(s, "occupancy_seq", 0), } # hub authority: asset_quantity (hub primary) + cash_balance diff --git a/trex_core/application/strategy_state.py b/trex_core/application/strategy_state.py new file mode 100644 index 0000000..b782665 --- /dev/null +++ b/trex_core/application/strategy_state.py @@ -0,0 +1,183 @@ +"""Slot strategy-state store (dcy-028 / REQ-079, BAT-042). + +A strategy daemon (e.g. trex-tsl) accumulates opaque state across a slot's +occupancy — for a Trailing Stop Loss the peak price since entry. `evaluate()` +must stay pure, so the state cannot live in the daemon (lost on restart) nor in +an external store (orphan-cleanup problem). Since the state's lifecycle matches +the slot occupancy exactly, trex-core stores it server-side keyed by +`(slot_id, strategy_id)`, round-trips it on the slots snapshot (read), accepts +version-CAS writes via a dedicated RPC, and deletes it when the slot closes. + +trex-core never interprets `data` (opaque UTF-8 string). It validates only +(1) valid UTF-8 and (2) a byte size cap — encoding (JSON/base64) is the +strategy's business (dcy-028 OQ-1). + +Occupancy guard (dcy-028 R-1 / REQ-081): a permanent slot is reused, so two +occupancies of the same slot both start at version 0. A write computed against +occupancy X must not land on a later occupancy Y. The slot carries a monotonic +`occupancy_seq`; the CAS rejects a write whose `expected_occupancy_seq` differs +from the slot's current value (`occupancy_changed`). The version CAS alone +cannot catch this at the first write (0 vs 0). + +Single-writer assumption: one daemon operates a given slot·strategy. The CAS is +for retry / duplicate / occupancy-turnover safety, not multi-writer concurrency. +""" +from __future__ import annotations + +import os +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional, Protocol +from uuid import UUID + +# dcy-028 OQ-1: hard ceiling for the opaque `data` blob, measured in UTF-8 +# bytes. Operators target 1~2KB for the known TSL use case; 4KB is the cap. +_STATE_CAP_BYTES = int(os.environ.get("TREX_STRATEGY_STATE_CAP_BYTES", "4096")) + +# Reject reason tokens (dcy-028 / REQ-080). The full enumerated set; consumers +# fail-closed on anything unrecognized. +REASON_OCCUPANCY_CHANGED = "occupancy_changed" +REASON_VERSION_CONFLICT = "version_conflict" +REASON_SLOT_INACTIVE = "slot_inactive" +REASON_STATE_TOO_LARGE = "state_too_large" + + +@dataclass(frozen=True) +class StrategyStateEntry: + """One stored opaque state value for a (slot_id, strategy_id).""" + + strategy_id: str + occupancy_seq: int + version: int + data: str + updated_at: datetime + + +@dataclass(frozen=True) +class SetResult: + """Outcome of a CAS set. ok=True carries the new version; else `reason`.""" + + ok: bool + version: Optional[int] = None + reason: Optional[str] = None + occupancy_seq: Optional[int] = None # current slot occupancy_seq on conflict + + +def _byte_len(data: str) -> int: + return len(data.encode("utf-8")) + + +class StrategyStateStore(Protocol): + def set( + self, + slot_id: UUID, + strategy_id: str, + *, + expected_occupancy_seq: int, + expected_version: int, + data: str, + current_occupancy_seq: int, + slot_active: bool, + ) -> SetResult: + """occupancy_seq + version CAS upsert of opaque `data` (dcy-028 FR-2).""" + ... + + def get_for_slots( + self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int] + ) -> dict[UUID, dict[str, StrategyStateEntry]]: + """Batched read for the snapshot (C-1). Returns slot_id -> {strategy_id: entry}. + + Only entries whose stored occupancy_seq matches the slot's current + occupancy_seq are returned (stale entries from a prior occupancy are + invisible — defense in depth alongside close-delete, dcy-028 FR-3).""" + ... + + def delete_all(self, slot_id: UUID) -> None: + """Delete all strategy_state for a slot (close hook, dcy-028 C-3).""" + ... + + +def _cas( + existing: Optional[StrategyStateEntry], + *, + expected_occupancy_seq: int, + expected_version: int, + data: str, + current_occupancy_seq: int, + slot_active: bool, +) -> SetResult: + """Pure CAS decision shared by all store implementations (dcy-028 FR-2).""" + if not slot_active: + return SetResult(ok=False, reason=REASON_SLOT_INACTIVE) + if expected_occupancy_seq != current_occupancy_seq: + return SetResult( + ok=False, reason=REASON_OCCUPANCY_CHANGED, occupancy_seq=current_occupancy_seq + ) + if _byte_len(data) > _STATE_CAP_BYTES: + return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE) + stored_version = existing.version if existing is not None else 0 + if expected_version != stored_version: + return SetResult(ok=False, reason=REASON_VERSION_CONFLICT, version=stored_version) + return SetResult(ok=True, version=stored_version + 1) + + +class InMemoryStrategyStateStore: + """Process-local strategy-state store (paper / tests / explicit double).""" + + def __init__(self) -> None: + # (slot_id, strategy_id) -> StrategyStateEntry + self._store: dict[tuple[UUID, str], StrategyStateEntry] = {} + self._lock = threading.Lock() + + def set( + self, + slot_id: UUID, + strategy_id: str, + *, + expected_occupancy_seq: int, + expected_version: int, + data: str, + current_occupancy_seq: int, + slot_active: bool, + ) -> SetResult: + with self._lock: + existing = self._store.get((slot_id, strategy_id)) + result = _cas( + existing, + expected_occupancy_seq=expected_occupancy_seq, + expected_version=expected_version, + data=data, + current_occupancy_seq=current_occupancy_seq, + slot_active=slot_active, + ) + if not result.ok: + return result + self._store[(slot_id, strategy_id)] = StrategyStateEntry( + strategy_id=strategy_id, + occupancy_seq=current_occupancy_seq, + version=result.version, # type: ignore[arg-type] + data=data, + updated_at=datetime.now(tz=timezone.utc), + ) + return result + + def get_for_slots( + self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int] + ) -> dict[UUID, dict[str, StrategyStateEntry]]: + wanted = set(slot_ids) + out: dict[UUID, dict[str, StrategyStateEntry]] = {} + with self._lock: + for (sid, strat), entry in self._store.items(): + if sid not in wanted: + continue + # stale-occupancy entries are invisible (dcy-028 FR-3 defense) + if entry.occupancy_seq != current_occupancy_seq.get(sid): + continue + out.setdefault(sid, {})[strat] = entry + return out + + def delete_all(self, slot_id: UUID) -> None: + with self._lock: + for key in [k for k in self._store if k[0] == slot_id]: + del self._store[key] diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index 55b1622..f6f2410 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -51,6 +51,11 @@ class Slot: # slippage_pct: effective limit = entry_price * (1 ± pct/100); None = no band. timeout_sec: Optional[int] = None slippage_pct: Optional[float] = None + # dcy-028 / REQ-081 (BAT-042): monotonic occupancy counter. Incremented when + # the slot begins a new occupancy (inactive→pending_buy), stable across one + # holding episode. Used by the strategy-state CAS to reject writes computed + # against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1). + occupancy_seq: int = 0 # Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5). @@ -78,6 +83,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "retired_at", # dcy-020 / REQ-056 (BAT-027) "timeout_sec", # dcy-022 / REQ-068 (BAT-032) "slippage_pct", # dcy-022 / REQ-068 (BAT-032) + "occupancy_seq", # dcy-028 / REQ-081 (BAT-042) }) diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index ee2002f..55bf362 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -39,12 +39,21 @@ _UNSET: Any = object() class SlotService: """Slot lifecycle service. Race-free transitions via advisory lock + transaction.""" - def __init__(self, repository: SlotRepository, instance_key: str = "default") -> None: + def __init__( + self, + repository: SlotRepository, + instance_key: str = "default", + *, + strategy_state_store: Any = None, + ) -> None: self._repo = repository # dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB # does not serialize transitions across instances (NFR-1). self._instance_key = instance_key self._lock_id = slot_advisory_lock_id(instance_key) + # dcy-028/REQ-081 (C-3): when injected, all strategy_state for a slot is + # deleted on close (→INACTIVE). None keeps legacy behaviour (no store). + self._strategy_state_store = strategy_state_store @property def instance_key(self) -> str: @@ -138,6 +147,11 @@ class SlotService: extra=extra or {}, ) ) + # dcy-028/REQ-081 (C-3): close deletes the slot's strategy_state so a + # reused slot starts empty. Covers every close path (strategy sell, + # safety-sell, forced-close, operator) since all funnel to →INACTIVE. + if to_state is SlotState.INACTIVE and self._strategy_state_store is not None: + self._strategy_state_store.delete_all(slot_id) return updated @staticmethod @@ -202,6 +216,14 @@ class SlotService: new_timeout_sec = None new_slippage_pct = None + # occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new + # occupancy (inactive→pending_buy). Stable across the rest of the + # holding episode; the INACTIVE branch above preserves it (monotonic). + if slot.status is SlotState.INACTIVE and to_state is SlotState.PENDING_BUY: + new_occupancy_seq = slot.occupancy_seq + 1 + else: + new_occupancy_seq = slot.occupancy_seq + # qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4). # Entering PENDING_SELL takes the caller qty AS-IS so a full sell # (qty=None) records a "sell all" sentinel rather than the stale held @@ -244,6 +266,7 @@ class SlotService: ), timeout_sec=new_timeout_sec, slippage_pct=new_slippage_pct, + occupancy_seq=new_occupancy_seq, updated_at=datetime.now(tz=timezone.utc), ) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 07a1ff7..49d88bb 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -33,6 +33,7 @@ from trex_core.adapters.messaging import ( ) from trex_core.adapters.persistence.migrate import apply_migrations from trex_core.adapters.persistence.postgresql import ( + PgStrategyStateStore, PostgreSQLIntentStore, PostgreSQLLedgerOutbox, PostgreSQLSlotRepository, @@ -54,6 +55,7 @@ from trex_core.application.messaging_handlers import ( make_control_rpc_handler, make_intent_rpc_handler, make_slots_rpc_handler, + make_strategy_state_set_handler, slot_transition_event_payload, ) from trex_core.application.portfolio import ( @@ -147,7 +149,14 @@ class Daemon: # Persistence + services self.slot_repo = PostgreSQLSlotRepository(config.dsn) self.intent_store = PostgreSQLIntentStore(config.dsn) - self.slot_service = SlotService(self.slot_repo, instance_key=config.instance_key) + # dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store. Injected + # into the slot service so close (→INACTIVE) deletes the slot's state. + self.strategy_state_store = PgStrategyStateStore(config.dsn) + self.slot_service = SlotService( + self.slot_repo, + instance_key=config.instance_key, + strategy_state_store=self.strategy_state_store, + ) self.intent_service = IntentService( self.slot_service, self.slot_repo, self.intent_store, resolve_symbol=self._resolve_symbol, @@ -284,10 +293,25 @@ class Daemon: watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec, ledger_outbox=self.outbox, max_active_slots=self.config.max_active_slots, + strategy_state_store=self.strategy_state_store, # dcy-028 C-1 ) transport.consume_rpc_forever( topology.slots_queue(self.config.instance_key), handler, self.stop) + def _strategy_state_set_loop(self) -> None: + # dcy-028 / REQ-080: opaque strategy-state write RPC (occupancy_seq+version CAS). + transport = _build_transport(self.config) + self._transports.append(transport) + try: + transport.declare_topology(self.config.instance_key) + except Exception: # noqa: BLE001 + pass + handler = make_strategy_state_set_handler( + self.slot_repo, self.strategy_state_store, + ) + transport.consume_rpc_forever( + topology.strategy_state_set_queue(self.config.instance_key), handler, self.stop) + def _control_rpc_loop(self) -> None: # REQ-043: operator control channel (pause/resume/list + kill_switch/resume_kill_switch). transport = _build_transport(self.config) @@ -338,6 +362,7 @@ class Daemon: ("trex-ledger", self._ledger_loop), ("trex-intent-rpc", self._intent_rpc_loop), ("trex-slots-rpc", self._slots_rpc_loop), + ("trex-strategy-state-set", self._strategy_state_set_loop), ("trex-control-rpc", self._control_rpc_loop), ("trex-snapshot-scheduler", self._snapshot_scheduler_loop), ] diff --git a/trex_core/runtime/reset.py b/trex_core/runtime/reset.py index 8d95ea3..1f48f29 100644 --- a/trex_core/runtime/reset.py +++ b/trex_core/runtime/reset.py @@ -38,6 +38,7 @@ SLOT_CORE_TABLES: tuple[str, ...] = ( "ledger_outbox", "snapshot_pause", "killswitch_exclusion", + "slot_strategy_state", # dcy-028 / REQ-079 (BAT-042) )