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:
@@ -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"
|
||||
Reference in New Issue
Block a user