From 9871008bb6fe8685ee273e3739f051389a522ad1 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 12 Jun 2026 18:16:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(paper):=20BAT-039=20=E2=80=94=20paper=20ord?= =?UTF-8?q?er=20seq=20Redis=20=EC=98=81=EC=86=8D=ED=99=94=20(=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EB=A9=B1=EB=93=B1=20=ED=82=A4=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B0=A9=EC=A7=80,=20REQ-011/issue=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-011 change-req(CHG-20260612-01)→구현→confirm-req-implemented(CHG-20260612-02) 전체 delivery. paper 한정 결정성 버그(프로덕션 무관). - seq_store.py 신규: SeqStore 포트 + RedisSeqStore(원자 INCR, 재시작 단조·영속) + InMemorySeqStore(폴백/테스트). REQ-016 token_cache 패턴 재사용. - paper.py: optional seq_store 주입. 미주입 시 기존 in-memory 동작 byte-for-byte 유지. - config.py/daemon.py: TREX_PAPER_REDIS_URL → RedisSeqStore wiring (key=trex:broker:paper:{instance_key}:order-seq). - 런타임 Redis 오류는 전파(silent in-memory 폴백 금지 — 버그 재현 방지). - tests: 재시작 단조성/correlation_id 비충돌/in-memory 폴백/fake redis INCR (5). 434 passed (+5), ruff clean. REQ-011 Approved→Implemented, BAT-039 release-candidate. issue #4 close. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../design.md | 42 +++++++ .../implementation.md | 31 ++++++ .../index.md | 33 ++++++ .../planning.md | 37 +++++++ .../verification.md | 39 +++++++ docs/batches/index.md | 1 + .../req-011_broker-port-and-paper-double.md | 100 ++++++++++++++++- docs/srs/index.md | 4 +- tests/adapters/broker/test_paper_seq_store.py | 104 ++++++++++++++++++ trex_core/adapters/broker/paper.py | 15 ++- trex_core/adapters/broker/seq_store.py | 57 ++++++++++ trex_core/runtime/config.py | 4 + trex_core/runtime/daemon.py | 9 ++ 13 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 docs/batches/bat-039_20260612_paper-seq-redis-persistence/design.md create mode 100644 docs/batches/bat-039_20260612_paper-seq-redis-persistence/implementation.md create mode 100644 docs/batches/bat-039_20260612_paper-seq-redis-persistence/index.md create mode 100644 docs/batches/bat-039_20260612_paper-seq-redis-persistence/planning.md create mode 100644 docs/batches/bat-039_20260612_paper-seq-redis-persistence/verification.md create mode 100644 tests/adapters/broker/test_paper_seq_store.py create mode 100644 trex_core/adapters/broker/seq_store.py diff --git a/docs/batches/bat-039_20260612_paper-seq-redis-persistence/design.md b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/design.md new file mode 100644 index 0000000..f80d616 --- /dev/null +++ b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/design.md @@ -0,0 +1,42 @@ +# BAT-039 Design + +- Status: confirmed +- Batch: BAT-039 + +## Architecture Summary +- paper 어댑터의 order seq 발번을 optional `SeqStore` 위로 옮긴다. 주입되면 영속 단조 카운터(Redis `INCR`)를, 미주입이면 기존 in-memory `_seq` 를 사용한다(byte-for-byte 기존 동작 유지). +- `SeqStore` 는 adapters/broker 레이어의 작은 포트로, REQ-016 `token_cache.py`(Redis best-effort)와 같은 패턴·같은 레이어에 둔다. 도메인은 의존하지 않는다(NFR-1 불변). +- 데몬은 `TREX_PAPER_REDIS_URL` 이 설정되면 `RedisSeqStore` 를 만들어 paper 브로커에 주입한다. + +## Changed Areas +- Modules: + - `trex_core/adapters/broker/seq_store.py`(신규): + - `SeqStore` Protocol — `next_seq() -> int` + - `RedisSeqStore(redis_url, *, key, redis_module=None)` — `INCR key` 로 원자·영속 단조 정수 반환. `from_url(socket_connect_timeout=2)`(token_cache 정합). + - `InMemorySeqStore` — thread-safe 카운터(테스트/명시 폴백) + - `trex_core/adapters/broker/paper.py` — `__init__(..., seq_store: Optional[SeqStore] = None)`; `submit_order` 에서 `seq = self._seq_store.next_seq()` (주입 시) / `self._seq += 1` (미주입) + - `trex_core/runtime/config.py` — `PaperBrokerConfig.redis_url: str = ""` + `from_config` `TREX_PAPER_REDIS_URL` + - `trex_core/runtime/daemon.py` — `_build_broker`: paper + `config.paper.redis_url` → `RedisSeqStore(url, key=f"trex:broker:paper:{config.instance_key}:order-seq")` 주입 +- Tests: + - `tests/adapters/broker/test_paper_seq_store.py`(신규) +- Docs: 없음(runtime-flows 흐름 불변 — 발번 영속화는 내부 결정성, 외부 흐름 동일) + +## Key Decisions +1. **`SeqStore.next_seq() -> int` 단일 메서드**: paper 가 필요로 하는 것은 단조 정수뿐. order_id 포맷(`PAPER-{seq:06d}`)은 paper 책임으로 유지. 포트는 최소. +2. **Redis `INCR`(원자·영속)**: GET/SET race 없이 단일 명령으로 단조 보장. 키 부재 시 0→1 로 시작해 첫 order_id 가 `PAPER-000001`(기존과 동일 시작값). +3. **키 스코프 = instance_key**: `trex:broker:paper:{instance_key}:order-seq`. 인스턴스별 독립 카운터(다중 인스턴스 교차 방지). 단일 인스턴스(default)는 단일 키. +4. **폴백 의미 = 설정 부재만**: `TREX_PAPER_REDIS_URL` 미설정 → seq store 미주입 → in-memory `_seq`(기존 동작). **런타임 Redis 오류는 전파**(silent in-memory 폴백 금지 — 그 폴백이 곧 버그 재현). paper 는 테스트 하네스이므로 오류 전파가 안전. (R-1) +5. **token_cache best-effort 와의 차이**: 토큰 캐시는 miss/error → 재발급(무해)이라 best-effort 폴백. seq 는 error → 폴백이 곧 멱등 키 충돌이므로 best-effort 폴백을 적용하지 않는다. + +## Edge Cases +- seq store 미주입: 기존 `self._seq += 1`, `PAPER-000001`부터. AC-1~3 불변. +- INCR 결과 > 999999: `f"{seq:06d}"` 는 최소 패딩 → `PAPER-1000000` 정상 표기, 고유성 유지. +- 재시작 모사(같은 key 공유 RedisSeqStore/InMemorySeqStore 인스턴스): 두 번째 PaperBroker 가 첫 인스턴스 이후 seq 로 시작 → 매도 order_id ≠ 직전 매수 → correlation_id 비충돌. +- 동시 submit(스레드): `INCR` 원자 / `InMemorySeqStore` 는 lock 으로 보호. + +## Architecture Impact +- none (runtime-flows 흐름 불변. 발번 경로 내부 결정성 보강만, 외부 계약/흐름 동일) + +## Reference Doc Update Plan +- project-structure.md: none (adapters/broker 에 파일 1개 추가 — 구조 패턴 불변, token_cache 와 동급) +- runtime-flows.md: none diff --git a/docs/batches/bat-039_20260612_paper-seq-redis-persistence/implementation.md b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/implementation.md new file mode 100644 index 0000000..3d8b67a --- /dev/null +++ b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/implementation.md @@ -0,0 +1,31 @@ +# BAT-039 Implementation + +- Status: completed +- Batch: BAT-039 + +## Plan Summary +- `seq_store.py` 신규(`SeqStore`/`RedisSeqStore`/`InMemorySeqStore`), paper 어댑터에 optional 주입, config/daemon wiring, 재시작 모사 회귀 테스트. + +## Changed Files +- `trex_core/adapters/broker/seq_store.py` (신규) — `SeqStore` Protocol, `RedisSeqStore`(INCR), `InMemorySeqStore` +- `trex_core/adapters/broker/paper.py` — `SeqStore` import, `__init__(seq_store=None)`, `submit_order` 발번 분기 +- `trex_core/runtime/config.py` — `PaperBrokerConfig.redis_url` + `TREX_PAPER_REDIS_URL` +- `trex_core/runtime/daemon.py` — `_build_broker` paper 경로 `RedisSeqStore` wiring(key=`trex:broker:paper:{instance_key}:order-seq`) +- `tests/adapters/broker/test_paper_seq_store.py` (신규, 5 테스트) + +## Execution Log +1. `seq_store.py`: `next_seq()` 단일 메서드 포트. `RedisSeqStore` 는 `from_url(socket_connect_timeout=2)` + 원자 `INCR`(키 부재 시 1부터 → 첫 order_id `PAPER-000001`). `InMemorySeqStore` 는 lock 보호 카운터. 런타임 Redis 오류는 전파(silent in-memory 폴백 금지, 버그 재현 방지). +2. `paper.py`: `seq_store` 주입 시 `next_seq()`, 미주입 시 기존 `self._seq += 1`(byte-for-byte 유지). `_lock` 안에서 발번. +3. `config.py`: `PaperBrokerConfig.redis_url: str = ""`, `from_config` 에서 `TREX_PAPER_REDIS_URL` 읽기. +4. `daemon.py`: `config.paper.redis_url` 있으면 `RedisSeqStore` 생성·주입(instance_key 스코프 키). +5. 테스트 5종: 재시작 단조성, 매수→재시작→매도 correlation_id 비충돌, (버그 문서화) store 없을 때 충돌 재현, in-memory 폴백 시작값, fake redis INCR 단조·공유. + +## Validation +- `pytest tests/adapters/broker/test_paper_seq_store.py` — 5 passed +- `pytest tests/` — **434 passed, 45 skipped** (BAT-038 429 → +5, 회귀 0) +- `ruff check trex_core tests` — All checks passed +- 폴백 회귀: `test_in_memory_fallback_starts_at_one` 으로 미주입 시 `PAPER-000001` 시작 확인(AC-1~3 기존 paper 테스트 회귀 없음). + +## Remaining Risks +- 없음. R-1(런타임 오류 silent 폴백)은 전파 설계로 제거, R-2(자리수 초과)는 `:06d` 최소 패딩으로 무해, R-3(다중 인스턴스 키 공유)은 instance_key 스코프로 분리. +- 라이브 Redis 통합 스모크는 운영 배포 단계 사항(테스트는 fake redis_module + InMemorySeqStore 로 INCR/단조 계약 검증). 실 KIS 는 본 변경 무관(odno 고유). diff --git a/docs/batches/bat-039_20260612_paper-seq-redis-persistence/index.md b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/index.md new file mode 100644 index 0000000..6f42a5d --- /dev/null +++ b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/index.md @@ -0,0 +1,33 @@ +# BAT-039: paper 브로커 order seq Redis 영속화 (CHG-20260612-01) + +- Status: release-candidate +- Profile: standard +- Source Discovery: 없음 (issue #4, dcy-003/REQ-011 change-req) +- Included REQ: REQ-011 (CHG-20260612-01) +- Excluded REQ: 없음 + +## Summary +- paper 브로커 order seq 가 데몬 재시작 시 0 으로 리셋되어 `PAPER-000001` 부터 재발번 → 결정적 `execution_id` 재생성 → 원장 멱등 키 `correlation_id`(side 미포함)가 직전 run 매수와 충돌 → `ON CONFLICT DO NOTHING` dedup 으로 정상 매도 원장이 조용히 누락되는 paper 한정 결정성 버그(issue #4)를 닫는다. paper 어댑터에 optional seq store(Redis INCR 영속, REQ-016 token_cache 패턴)를 주입하고 미구성 시 in-memory 폴백. + +## Scope +- In Scope: + - `trex_core/adapters/broker/seq_store.py` 신규 — `SeqStore` 포트 + `RedisSeqStore`(INCR) + `InMemorySeqStore` + - `trex_core/adapters/broker/paper.py` — optional `seq_store` 주입, `submit_order` 발번 경로 + - `trex_core/runtime/config.py` — `PaperBrokerConfig.redis_url`(`TREX_PAPER_REDIS_URL`) + - `trex_core/runtime/daemon.py` — `_build_broker` paper 경로 seq store wiring + - 테스트: 재시작 모사(seq store 공유) 단조성 + correlation_id 비충돌 + in-memory 폴백 +- Out of Scope: + - 원장 `correlation_id` 에 side 추가(대안 미채택 — 프로덕션 계약 영향) + - KIS 어댑터(odno 고유, 충돌 없음) + - 과거 누락 원장 행 소급 보정 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- paper 한정 테스트 하네스 결정성 문제 — 실 KIS 는 broker `odno` 가 주문마다 고유해 충돌 없음(프로덕션 버그 아님). +- seq store 미주입(Redis 미구성) 시 기존 in-memory `_seq` 동작을 byte-for-byte 유지(AC-1~3 불변, CI 영향 최소). +- Redis 인프라는 REQ-016 `RedisTokenCache` 패턴(best-effort, `from_url(socket_connect_timeout=2)`)을 재사용. diff --git a/docs/batches/bat-039_20260612_paper-seq-redis-persistence/planning.md b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/planning.md new file mode 100644 index 0000000..50392b9 --- /dev/null +++ b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/planning.md @@ -0,0 +1,37 @@ +# BAT-039 Planning + +- Status: confirmed +- Batch: BAT-039 +- Profile: standard + +## Included REQ +- REQ-011 (CHG-20260612-01): paper order seq Redis 영속화 + in-memory 폴백. AC-4 신규. AC-1~3 불변. + +## Delivery Plan +1. `seq_store.py` 신규 — `SeqStore` Protocol(`next_seq() -> int`), `RedisSeqStore`(Redis `INCR`, 영속·원자), `InMemorySeqStore`(thread-safe 카운터, 폴백/테스트용). token_cache 패턴(`from_url(socket_connect_timeout=2)`) 정합. +2. `paper.py` — `__init__(..., seq_store: Optional[SeqStore] = None)`. `submit_order`: `seq_store` 주입 시 `seq_store.next_seq()`, 미주입 시 기존 `self._seq += 1`(byte-for-byte 유지). +3. `config.py` — `PaperBrokerConfig.redis_url: str = ""`, `from_config` 에 `TREX_PAPER_REDIS_URL` 읽기. +4. `daemon.py` — `_build_broker` paper 경로: `config.paper.redis_url` 있으면 `RedisSeqStore` 생성·주입. +5. 테스트 — 동일 seq store 공유 두 PaperBroker(재시작 모사) order_id 단조 증가, 매수→재시작→매도 correlation_id 비충돌, 미주입 시 `PAPER-000001` 폴백, AC-1~3 회귀. + +## Dependencies +- Internal: + - `trex_core/adapters/broker/token_cache.py` — Redis best-effort 패턴 참조 + - `trex_core/application/broker_port.py` — `OrderRequest`/`OrderAck` 불변 + - execution_id 합성(`paper:{order_id}:{filled_qty}`) — order_id 단조성에 의존 +- External: + - Redis(운영). 테스트는 fake/in-memory redis_module 또는 InMemorySeqStore 로 충분. + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 신규 `SeqStore` 포트(인터페이스 추가) + Redis 키 네임스페이스/스코프(instance_key) + INCR 원자성·런타임 오류 처리 + 폴백 의미를 design 에서 확정해야 함. + +## Milestones +1. Design confirmed — SeqStore 계약, Redis 키, 폴백 의미 확정 +2. Implementation — seq_store + paper + config + daemon + 테스트 +3. Verification — AC-4 + AC-1~3 회귀 + 전체 회귀 + ruff + +## Risks +- R-1 (Medium): Redis 런타임 오류 시 in-memory 로 silent 폴백하면 버그 재현 위험 — 완화: 미주입(설정 부재)만 폴백, 런타임 오류는 전파(test 하네스이므로 허용). design Key Decision 에서 명시. +- R-2 (Low): order_id 가 6자리 초과(INCR 누적) 시 `PAPER-{seq:06d}` 포맷 — `:06d` 는 최소 패딩이라 초과 자리수도 정상 표기, 충돌 없음. +- R-3 (Low): 다중 인스턴스가 같은 Redis 키 공유 시 seq 교차 — 단조·고유성 유지되므로 correctness 무해. instance_key 스코프로 분리 가능(design). diff --git a/docs/batches/bat-039_20260612_paper-seq-redis-persistence/verification.md b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/verification.md new file mode 100644 index 0000000..a84df92 --- /dev/null +++ b/docs/batches/bat-039_20260612_paper-seq-redis-persistence/verification.md @@ -0,0 +1,39 @@ +# BAT-039 Verification + +- Status: confirmed +- Batch: BAT-039 + +## Acceptance Mapping + +- REQ-011 AC-1 (submit accepted, 동기 단축 없음): + - 변경 범위 밖(발번 경로만 수정, 비동기 계약 불변). Evidence: 기존 paper 테스트 전체 회귀 — `pytest tests/` 434 passed. + +- REQ-011 AC-2 (결정적 fill 시나리오 — 즉시/N틱/부분/거부): + - 변경 범위 밖. Evidence: 기존 paper 더블 시나리오 테스트 회귀 없음(434 passed). + +- REQ-011 AC-3 (도메인 순수성 — broker 의존 0, BrokerPort 구현 adapters 한정): + - `seq_store.py` 는 adapters/broker 레이어에 위치, 도메인 미참조. Evidence: 도메인 import 스윕 회귀 없음(전체 회귀 green). + +- REQ-011 AC-4 (seq store 재시작 단조 + correlation_id 비충돌 + 미주입 폴백): + - Evidence: + - `test_shared_seq_store_is_monotonic_across_restart`: 같은 store 공유 두 broker(재시작 모사) → `PAPER-000001` → `PAPER-000002` 단조 PASSED. + - `test_post_restart_sell_does_not_collide_with_prior_buy`: 매수→재시작→매도 correlation_id 비충돌 PASSED. + - `test_without_store_restart_reproduces_collision`: (버그 문서화) store 없을 때 두 broker 모두 `PAPER-000001` → correlation_id 동일(원장 dedup 발생) PASSED — 수정 전 동작 고정. + - `test_in_memory_fallback_starts_at_one`: 미주입 시 `PAPER-000001` 부터(기존 동작 유지) PASSED. + - `test_redis_seq_store_incr_is_monotonic_and_shared`: fake redis INCR 로 재시작 모사 단조·공유 PASSED. + +## Evidence + +- Test: + - `pytest tests/adapters/broker/test_paper_seq_store.py` — 5 passed (신규) + - `pytest tests/` — **434 passed, 45 skipped** (BAT-038 429 → +5, 회귀 0) + - `ruff check trex_core tests` — All checks passed + +- Manual / Architecture Impact (none): + - design.md Architecture Impact = none — `docs/project-structure.md`, `docs/runtime-flows.md` 동기화 불필요(발번 경로 내부 결정성 보강, 외부 흐름/계약 불변). `seq_store.py` 는 token_cache 와 동급 adapters 파일 추가로 구조 패턴 불변. + - `_build_broker` wiring(`TREX_PAPER_REDIS_URL` → `RedisSeqStore`, instance_key 스코프 키) 코드 리뷰 확인. + +## Result +- Ready for Release: true +- Blocking Issues: 없음 + - 라이브 Redis 통합 스모크는 REL 배포 단계 사항. 단위 테스트가 INCR/단조/폴백 계약을 fake redis_module + InMemorySeqStore 로 검증하므로 release-ready 차단 아님. 실 KIS 무관(odno 고유). diff --git a/docs/batches/index.md b/docs/batches/index.md index 765b496..846b8f0 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -46,3 +46,4 @@ | BAT-036 | standard | confirmed | REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) | dcy-024 | [bat-036](./bat-036_20260611_safety-sell-disable-mode/) | | BAT-037 | standard | release-candidate | REQ-073 (CHG-20260612-01) | dcy-025 (REQ-073 change-req) | [bat-037](./bat-037_20260612_slots-rpc-available-slots/) | | BAT-038 | standard | release-candidate | REQ-018 (CHG-20260612-01) | dcy-026 (issue #24) | [bat-038](./bat-038_20260612_sell-transaction-tax-fee/) | +| BAT-039 | standard | release-candidate | REQ-011 (CHG-20260612-01) | 없음 (issue #4) | [bat-039](./bat-039_20260612_paper-seq-redis-persistence/) | diff --git a/docs/srs/Integration/req-011_broker-port-and-paper-double.md b/docs/srs/Integration/req-011_broker-port-and-paper-double.md index 6a62369..fd7afad 100644 --- a/docs/srs/Integration/req-011_broker-port-and-paper-double.md +++ b/docs/srs/Integration/req-011_broker-port-and-paper-double.md @@ -10,20 +10,23 @@ ## Requirement - `BrokerPort`(application 포트)에 `submit_order(side, symbol, qty, ...) -> broker_order_id`, `cancel_order(broker_order_id)`, `poll_fill(broker_order_id) -> FillStatus(accepted/partially_filled/filled/rejected/cancelled, avg_price, filled_qty)` 를 정의한다. paper/stub 어댑터는 동기 단축 없이 submit→poll 비동기 계약을 따르되 체결 시점·부분체결·거부를 프로그래밍 가능한 결정적 더블로 제공한다. 어댑터는 adapters 레이어에 두고 도메인은 의존하지 않는다. +- paper 어댑터의 order seq 발번은 optional seq store(주입 가능) 위에서 동작한다. seq store 가 주입되면 order seq 는 데몬 재시작 간에도 단조 증가해야 하며(Redis 영속, REQ-016 `RedisTokenCache` 패턴 재사용·best-effort), 미주입 시 기존 in-memory 카운터로 폴백한다. 이는 재시작 후 결정적 `execution_id` 재생성으로 인한 원장 `correlation_id` 충돌(매도 원장 누락)을 방지하기 위한 paper 한정 결정성 보강이다(issue #4). ## Acceptance Criteria 1. Given `BrokerPort` 정의, When paper 어댑터로 `submit_order`, Then `accepted` + broker_order_id 를 반환하고 즉시 `filled` 로 단축하지 않는다(다음 `poll_fill` 에서 체결). 2. Given paper 더블의 fill 시나리오 설정(즉시/N틱 후/부분/거부), When `poll_fill` 반복, Then 설정대로 결정적으로 상태가 진행된다. 3. Given 코드, When import 스윕, Then `trex_core.domain.slot` 은 broker/네트워크 의존이 0이고 BrokerPort 구현은 adapters 레이어에만 있다. (NFR-1/NFR-2) +4. Given paper 어댑터에 seq store 가 주입된 상태, When 데몬 재시작(seq store 영속)을 모사해 두 번째 인스턴스에서 `submit_order`, Then order seq 는 첫 인스턴스 이후로 단조 증가하고, 같은 자산·같은 수량의 매도가 직전 run 매수와 `correlation_id` 가 충돌하지 않는다. seq store 미주입 시 기존 in-memory `PAPER-000001` 발번으로 폴백하며 AC-1~3 비동기 계약은 불변이다. (issue #4) ## Impacted Area - Docs: docs/interface-contract.md(브로커 경계) — REQ-015 에서 정합 -- Modules: trex_core/application(BrokerPort), trex_core/adapters/broker(paper/stub) -- Tests: paper 더블 fill 시나리오 테스트 +- Modules: trex_core/application(BrokerPort), trex_core/adapters/broker(paper/stub, `seq_store.py` 신규), trex_core/runtime(config `PaperBrokerConfig.redis_url`, daemon `_build_broker` wiring) +- Tests: paper 더블 fill 시나리오 테스트, paper seq store 재시작 단조성 + correlation_id 비충돌 + in-memory 폴백 테스트 ## Notes - 불변식 B 의 단일 경계. KIS 어댑터는 REQ-012, 집행 루프는 REQ-013. - 출처: dcy-003 FR-1(포트+paper), NFR-1/NFR-2 흡수. +- (issue #4/CHG-20260612-01) paper seq Redis 영속화는 paper 한정 테스트 하네스 결정성 보강이며 프로덕션(KIS `odno` 고유) 버그가 아니다. 원장 `correlation_id` 에 side 를 추가하는 대안 대신, 발번 단조성 보강(영향 최소·계약 불변)을 선택했다. Redis 인프라는 REQ-016 token_cache 패턴을 재사용한다. ## Change Log @@ -147,3 +150,96 @@ #### Validation Plan - BAT-004 verification evidence 재참조로 충분. + +### CHG-20260612-01 + +- Date: 2026-06-12 +- Author: Justin Ko + +#### Change Summary + +- (change-req, issue #4) paper 브로커 order seq 를 **재시작 간 단조 증가**하도록 영속화한다. 현재 `_seq` in-memory 카운터는 데몬 재시작 시 0 으로 리셋되어 `PAPER-000001` 부터 재발번 → 결정적 `execution_id`(`paper:{order_id}:{filled_qty}`) 재생성 → 원장 멱등 키 `correlation_id = {asset_id}:{order_id}:{execution_id}`(side 미포함)가 직전 run 의 매수와 충돌 → `ON CONFLICT DO NOTHING` dedup 으로 **정상 매도 원장 행이 조용히 누락**. paper 어댑터에 optional seq store(Redis-backed, REQ-016 `RedisTokenCache` 패턴 재사용)를 주입해 해소하고, Redis 미구성 시 기존 in-memory 동작으로 폴백. 신규 AC-4 가 추가 구현·검증을 필요로 하므로 **Implemented → Approved 되돌림**. + +#### Intent + +- paper 모드 테스트 하네스의 재시작 후 멱등 키 충돌(매도 원장 누락)을 제거한다. 실 KIS 는 broker order_id(`odno`)가 주문마다 고유해 충돌이 없으므로 **프로덕션 버그가 아닌 paper 한정 결정성 문제**다. 기존 BrokerPort/paper 비동기 계약(AC-1~3)은 불변, seq 발번 영속화만 추가. + +#### Acceptance Criteria Delta + +- Added: + - AC-4: paper 브로커에 seq store 가 주입되면 order seq 는 재시작 후에도 단조 증가하고, 같은 자산·같은 수량의 매도가 직전 run 의 매수와 `correlation_id` 가 충돌하지 않는다. seq store 미주입(Redis 미구성) 시 기존 in-memory `_seq` 동작으로 폴백하며 비동기 fill 계약(AC-1~3)은 불변이다. +- Updated: + - 없음 +- Removed: + - 없음 + +#### Impacted Area Delta + +- Docs: + - docs/srs/index.md (Register Implemented → Approved) +- Modules: + - `trex_core/adapters/broker/paper.py`(optional seq store 주입 + `submit_order` seq 발번 경로) + - `trex_core/adapters/broker/seq_store.py`(신규: `SeqStore` 포트 + `RedisSeqStore`(INCR 기반, best-effort) + `InMemorySeqStore` 폴백, token_cache 패턴 정합) + - `trex_core/runtime/config.py`(`PaperBrokerConfig.redis_url` 추가) + - `trex_core/runtime/daemon.py`(`_build_broker` paper 경로 seq store wiring) +- Tests: + - 재시작 시뮬레이션(seq store 공유) 후 매도 correlation_id 비충돌 + 폴백 동작 단위 테스트 + +#### Delivery Trace Delta + +- Batches: + - BAT-039 (예정, issue #4) +- Releases: + - none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-039 에서 신규 AC-4 + AC-1~3 회귀) +- Existing Implementation Invalidated: no (기존 비동기 계약/도메인 순수성 불변, 발번 경로에 optional 영속화만 추가). 단 신규 AC-4 미구현으로 Implemented 유지 불가 → Approved. +- Status Recommendation: Implemented → **Approved** (구현·검증 후 confirm-req-implemented 로 복귀) +- Follow-up Action: confirm-req → draft-batch(BAT-039) → 구현·검증 → confirm-req-implemented → issue #4 close + +#### Validation Plan + +- BAT-039: (a) 같은 seq store 를 공유한 두 PaperBroker 인스턴스(재시작 모사)에서 두 번째 인스턴스의 첫 order_id 가 첫 인스턴스 이후로 단조 증가, (b) 매수 후 재시작 매도 시 correlation_id 비충돌(원장 dedup 미발생), (c) seq store 미주입 시 기존 `PAPER-000001` 부터 in-memory 동작 폴백, (d) AC-1~3 기존 paper 테스트 회귀 없음. `pytest` 전체 + `ruff check` clean. + +### CHG-20260612-02 + +- Date: 2026-06-12 +- Author: Justin Ko + +#### Change Summary + +- Status 변경: Approved → **Implemented** (confirm-req-implemented, 근거 batch BAT-039 release-candidate). + +#### Intent + +- BAT-039 verification.md 의 acceptance mapping + evidence(434 passed / seq store 재시작 단조·correlation_id 비충돌·in-memory 폴백 / fake redis INCR 단조)로 AC-1~4 충족 → 구현 완료 동기화. + +#### Acceptance Criteria Delta + +- Added/Updated/Removed: 없음 (상태 전이만) + +#### Impacted Area Delta + +- Docs/Modules/Tests: 없음 (상태 전이만) + +#### Delivery Trace Delta + +- Batches: + - BAT-039 (release-candidate) +- Releases: + - none (release 편성은 별도 진행) + +#### Revalidation Impact + +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: Implemented +- Follow-up Action: draft-release 로 release 편성(BAT-039 포함) + issue #4 close + +#### Validation Plan + +- BAT-039 verification evidence 재참조로 충분. + - AC-4: `test_shared_seq_store_is_monotonic_across_restart`, `test_post_restart_sell_does_not_collide_with_prior_buy`, `test_in_memory_fallback_starts_at_one`, `test_redis_seq_store_incr_is_monotonic_and_shared`. + - AC-1~3: 전체 회귀 434 passed(기존 paper 더블 시나리오 + 도메인 순수성 회귀 없음). diff --git a/docs/srs/index.md b/docs/srs/index.md index 30b10cb..e5b9d9b 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -159,8 +159,8 @@ ## Recent Change Log Summary +- 2026-06-12 :: REQ-011 Approved → **Implemented** (confirm-req-implemented, BAT-039): paper order seq Redis 영속화 구현·검증 완료. 434 passed (seq store 5 신규 포함), 재시작 단조·correlation_id 비충돌·in-memory 폴백 green. Blocking Issues 없음. issue #4 close. +- 2026-06-12 :: REQ-011 change-req (CHG-20260612-01, issue #4): paper 브로커 order seq Redis 영속화 — 재시작 후 `correlation_id` 충돌(매도 원장 누락) 방지. optional seq store(RedisSeqStore INCR + in-memory 폴백) 주입. AC-4 신규. 기존 비동기 계약 불변이나 신규 AC 미구현 → Implemented → **Approved** 되돌림. BAT-039 구현·검증 후 복귀. paper 한정(프로덕션 버그 아님). - 2026-06-12 :: REQ-018 Approved → **Implemented** (confirm-req-implemented, BAT-038): 매도 증권거래세 정책 계산 + `tax=0.0` 제거 구현·검증 완료. 429 passed (fee·tax 정책 6 신규 포함), issue #24 1,376.9원 회귀 green, `tax=0.0` 하드코딩 grep 0건, runtime-flows 동기화. Blocking Issues 없음. - 2026-06-12 :: REQ-018 change-req (CHG-20260612-01, dcy-026/issue #24): 매도 증권거래세 누락 정정 — `FillStatus.tax` 매도 정책 세율(0.2%) 계산, `tax=0.0` 하드코딩(paper/kis) 제거, 단일 정책 지점 + env(`TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`). AC-3 갱신·AC-6~9 신규. `tax=0` evidence 무효 → Implemented → **Approved** 되돌림. BAT-038 구현·검증 후 복귀. - 2026-06-12 :: REQ-073 Approved → **Implemented** (confirm-req-implemented, BAT-037): `available_slots: int|null` capacity hint 추가. AC-3 반전·AC-6 신규. 16 tests passed (AC-6 3개 포함), 423 전체 회귀 없음. Blocking Issues 없음. -- 2026-06-12 :: REQ-073 change-req (CHG-20260612-01): 응답 payload 에 `available_slots: int|null` 추가. AC-3 반전(available_slots 포함), AC-6 신규(hint 값 규칙). BAT-035 AC-3 evidence 무효 → Implemented → **Approved** 되돌림. 차기 batch 구현·검증 후 복귀. -- 2026-06-11 :: REQ-058 change-req (CHG-20260611-01): `reconcile_universe` 슬롯 생성 시 `symbol` 누락 수정. 시그니처 `set[str]` → `dict[str, str]`(portfolio_id→symbol), INSERT에 symbol 포함, `symbol IS NULL` 기존 슬롯 backfill 추가. AC-5 신규 추가. 기존 AC 1~4 evidence 유효 → **Implemented 유지**. diff --git a/tests/adapters/broker/test_paper_seq_store.py b/tests/adapters/broker/test_paper_seq_store.py new file mode 100644 index 0000000..c949ff2 --- /dev/null +++ b/tests/adapters/broker/test_paper_seq_store.py @@ -0,0 +1,104 @@ +"""Paper broker order-seq persistence tests (REQ-011 CHG-20260612-01, issue #4). + +Reproduces the restart collision: without a persistent seq store, a fresh +PaperBroker re-mints PAPER-000001, so a post-restart sell's correlation_id +(asset_id:order_id:execution_id — no side) collides with the prior buy and the +ledger dedups it away. A shared SeqStore keeps the seq monotonic across the +restart so the collision cannot happen. +""" +from __future__ import annotations + +from trex_core.adapters.broker.paper import PaperBroker +from trex_core.adapters.broker.seq_store import InMemorySeqStore, RedisSeqStore +from trex_core.application.broker_port import OrderRequest + + +def _submit(broker, side, symbol="005930", qty=10): + return broker.submit_order( + OrderRequest(slot_id="s1", side=side, symbol=symbol, qty=qty) + ).broker_order_id + + +def _correlation(asset_id, order_id, filled_qty): + # mirrors LedgerPort correlation_id = {asset_id}:{order_id}:{execution_id}, + # execution_id = paper:{order_id}:{filled_qty}; side is NOT part of the key. + return f"{asset_id}:{order_id}:paper:{order_id}:{filled_qty}" + + +# --- AC-4: restart monotonicity + no correlation_id collision --------------- + +def test_shared_seq_store_is_monotonic_across_restart(): + store = InMemorySeqStore() + run1 = PaperBroker(seq_store=store) + first = _submit(run1, "buy") # PAPER-000001 + + # simulate daemon restart: brand-new broker, SAME persistent store + run2 = PaperBroker(seq_store=store) + second = _submit(run2, "sell") # must continue, not reset + + assert first == "PAPER-000001" + assert second == "PAPER-000002" + assert first != second + + +def test_post_restart_sell_does_not_collide_with_prior_buy(): + store = InMemorySeqStore() + buy_oid = _submit(PaperBroker(seq_store=store), "buy", qty=10) + # restart, then sell the same asset/qty + sell_oid = _submit(PaperBroker(seq_store=store), "sell", qty=10) + + asset_id = "PF-1" + assert _correlation(asset_id, buy_oid, 10) != _correlation(asset_id, sell_oid, 10) + + +def test_without_store_restart_reproduces_collision(): + # Documents the bug: no store => each new broker resets to PAPER-000001. + buy_oid = _submit(PaperBroker(), "buy", qty=10) + sell_oid = _submit(PaperBroker(), "sell", qty=10) # restart + + assert buy_oid == sell_oid == "PAPER-000001" + asset_id = "PF-1" + # same correlation_id => ledger ON CONFLICT DO NOTHING drops the sell + assert _correlation(asset_id, buy_oid, 10) == _correlation(asset_id, sell_oid, 10) + + +# --- in-memory fallback (no store injected) keeps prior behaviour ----------- + +def test_in_memory_fallback_starts_at_one(): + broker = PaperBroker() # no seq_store + assert _submit(broker, "buy") == "PAPER-000001" + assert _submit(broker, "buy") == "PAPER-000002" + + +# --- RedisSeqStore over a fake redis module (INCR semantics) ---------------- + +class _FakeRedis: + def __init__(self): + self.store: dict[str, int] = {} + + def incr(self, key): + self.store[key] = self.store.get(key, 0) + 1 + return self.store[key] + + +class _FakeRedisModule: + def __init__(self, client): + self._client = client + + def from_url(self, url, **kwargs): # noqa: ARG002 - signature parity + return self._client + + +def test_redis_seq_store_incr_is_monotonic_and_shared(): + fake = _FakeRedis() + module = _FakeRedisModule(fake) + key = "trex:broker:paper:default:order-seq" + + s1 = RedisSeqStore("redis://x", key=key, redis_module=module) + b1 = PaperBroker(seq_store=s1) + assert _submit(b1, "buy") == "PAPER-000001" + + # restart: new store instance over the SAME backing redis + key + s2 = RedisSeqStore("redis://x", key=key, redis_module=module) + b2 = PaperBroker(seq_store=s2) + assert _submit(b2, "sell") == "PAPER-000002" diff --git a/trex_core/adapters/broker/paper.py b/trex_core/adapters/broker/paper.py index 1a8201e..01f79c0 100644 --- a/trex_core/adapters/broker/paper.py +++ b/trex_core/adapters/broker/paper.py @@ -11,6 +11,7 @@ import threading from decimal import Decimal from typing import Optional +from trex_core.adapters.broker.seq_store import SeqStore from trex_core.application.broker_port import ( BalanceItem, FillStatus, @@ -47,12 +48,18 @@ class PaperBroker: fill_after_polls: int = 0, qty_per_poll: Optional[int] = None, reject_symbols: Optional[set[str]] = None, + seq_store: Optional[SeqStore] = None, ) -> None: self._fill_after = fill_after_polls self._qty_per_poll = qty_per_poll self._reject = reject_symbols or set() self._orders: dict[str, _Order] = {} self._seq = 0 + # REQ-011 CHG-20260612-01 (issue #4): when injected, order seq is minted + # from a persistent monotonic store so it survives daemon restarts and a + # post-restart sell does not collide its correlation_id with a prior buy. + # Unset (no Redis configured) keeps the byte-for-byte in-memory counter. + self._seq_store = seq_store self._lock = threading.Lock() # REQ-038: programmable current price for safety-sell scenario tests. self._prices: dict[str, Decimal] = {} @@ -62,8 +69,12 @@ class PaperBroker: def submit_order(self, req: OrderRequest) -> OrderAck: with self._lock: - self._seq += 1 - oid = f"PAPER-{self._seq:06d}" + if self._seq_store is not None: + seq = self._seq_store.next_seq() + else: + self._seq += 1 + seq = self._seq + oid = f"PAPER-{seq:06d}" self._orders[oid] = _Order(oid, req) return OrderAck(accepted=True, broker_order_id=oid) diff --git a/trex_core/adapters/broker/seq_store.py b/trex_core/adapters/broker/seq_store.py new file mode 100644 index 0000000..7271fa4 --- /dev/null +++ b/trex_core/adapters/broker/seq_store.py @@ -0,0 +1,57 @@ +"""Paper broker order-seq store (REQ-011 CHG-20260612-01, issue #4). + +The paper broker mints order ids from a counter (`PAPER-{seq:06d}`). When the +counter lives only in process memory it resets to 0 on daemon restart, so the +first order after a restart re-mints `PAPER-000001` and the deterministic +`execution_id` (`paper:{order_id}:{filled_qty}`) collides with the previous +run. Since the ledger idempotency key `correlation_id` does not include side, +a post-restart sell then dedups against the prior buy and its ledger row is +silently dropped. + +This module persists the seq across restarts. `RedisSeqStore` uses an atomic +`INCR` (same Redis pattern as REQ-016 token_cache); `InMemorySeqStore` is the +explicit process-local double for tests. When no store is injected the paper +broker keeps its byte-for-byte in-memory behaviour (no Redis configured). + +Unlike the token cache, a runtime Redis error here is NOT degraded to an +in-memory counter — that fallback would re-create the very collision this +fixes — so errors propagate (paper is a test harness; surfacing is safe). +""" +from __future__ import annotations + +import threading +from typing import Any, Protocol + + +class SeqStore(Protocol): + def next_seq(self) -> int: + """Return the next strictly-increasing order seq (monotonic, persistent).""" + ... + + +class RedisSeqStore: + """Redis-backed monotonic seq via atomic INCR (persists across restarts).""" + + def __init__(self, redis_url: str, *, key: str, redis_module: Any = None) -> None: + if redis_module is None: + import redis as redis_module # type: ignore[import-untyped] + self._client = redis_module.from_url(redis_url, socket_connect_timeout=2) + self._key = key + + def next_seq(self) -> int: + # INCR is atomic and creates the key at 1 on first use, so the first + # order id is PAPER-000001 (same start value as the in-memory counter). + return int(self._client.incr(self._key)) + + +class InMemorySeqStore: + """Process-local monotonic seq (tests / explicit double). Shared if reused.""" + + def __init__(self, *, start: int = 0) -> None: + self._seq = start + self._lock = threading.Lock() + + def next_seq(self) -> int: + with self._lock: + self._seq += 1 + return self._seq diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 3b88802..07da5ba 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -91,6 +91,9 @@ class PaperBrokerConfig: fill_after_polls: int = 0 qty_per_poll: Optional[int] = None reject_symbols: frozenset[str] = frozenset() + # REQ-011 CHG-20260612-01 (issue #4): optional Redis URL for a persistent + # order-seq store. Empty => in-memory counter (prior behaviour). + redis_url: str = "" @dataclass(frozen=True) @@ -257,6 +260,7 @@ class DaemonConfig: reject_symbols=frozenset( s.strip() for s in reject_raw.split(",") if s.strip() ), + redis_url=os.environ.get("TREX_PAPER_REDIS_URL", "").strip(), ) portfolio_hub: Optional[PortfolioHubConfig] = None pf_base = os.environ.get("TREX_PORTFOLIO_HUB_BASE_URL", "").strip() diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 369fb24..07a1ff7 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -84,10 +84,19 @@ def _build_broker(config: DaemonConfig) -> BrokerPort: base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live, token_cache=token_cache, ) + seq_store = None + if config.paper.redis_url: + from trex_core.adapters.broker.seq_store import RedisSeqStore + + seq_store = RedisSeqStore( + config.paper.redis_url, + key=f"trex:broker:paper:{config.instance_key}:order-seq", + ) return PaperBroker( fill_after_polls=config.paper.fill_after_polls, qty_per_poll=config.paper.qty_per_poll, reject_symbols=set(config.paper.reject_symbols), + seq_store=seq_store, )