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
+13 -2
View File
@@ -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)
+57
View File
@@ -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