fix(paper): BAT-039 — paper order seq Redis 영속화 (재시작 멱등 키 충돌 방지, REQ-011/issue #4)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:16:11 +09:00
co-authored by Claude Opus 4.8
parent 0e5ce32f6b
commit 9871008bb6
13 changed files with 470 additions and 6 deletions
@@ -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"