feat(ledger): dcy-018 option D cash/position write-back + 추정 현금 (BAT-023)

ledger write 응답의 portfolio-hub 권위 cash/quantity 를 스냅샷에 write-back 해
매수/매도 직후 "수량 live·현금 stale" 비대칭 제거(option D 활성화). portfolio-hub#8
(*_after 응답) 동작 중 확인으로 그간 deferred 됐던 REQ-030 을 닫음.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-018 OQ-3 a).
- REQ-017: record_trade → LedgerWriteOutcome{result, cash_balance_after,
  asset_quantity_after}. 어댑터가 응답 root flat *_after 파싱(new+duplicate, 누락 None)
- REQ-019: LedgerWriter 에 SnapshotStore 주입 → new+duplicate write-back
  (best-effort, *_after 절대값이라 멱등). LedgerOutbox.pending_cash_delta()
- REQ-029: 추정 현금 오버레이(_apply_cash_estimate, cash_estimated 라벨, OQ-4 i)
  + 슬롯 오버레이 유지(OQ-1 B). 미정산 outbox delta 기반, write-back 수렴
- REQ-030: option D 활성화 → Approved→Implemented

best-effort 격리(NFR-1): write-back/추정 실패가 ledger 멱등·청산 무차단.
배치 번호: 동시 커밋된 BAT-022(slot-state, dcy-016)와 충돌해 BAT-023 으로 배정.

검증: 단위 350 passed/35 skipped, 인프라 게이트(PG+RabbitMQ) 385 passed, ruff clean.
신규 test_ledger_option_d + adapter/PG 테스트. BAT-023 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:50:56 +09:00
co-authored by Claude Opus 4.8
parent 63da87c9b3
commit 2bfecd59a8
26 changed files with 969 additions and 34 deletions
+18 -5
View File
@@ -8,6 +8,7 @@ from trex_core.application.ledger_port import (
LedgerPermanentError,
LedgerRetryableError,
LedgerTrade,
LedgerWriteOutcome,
LedgerWriteResult,
)
@@ -32,6 +33,8 @@ class InMemoryLedger:
fail_n_then_succeed: int = 0,
conflict_executions: set[str] | None = None,
permanent_executions: set[str] | None = None,
cash_after: float | None = None,
quantity_after: float | None = None,
) -> None:
self._trades: dict[str, LedgerTrade] = {}
self._order: list[LedgerTrade] = []
@@ -39,8 +42,12 @@ class InMemoryLedger:
self._fail_remaining = fail_n_then_succeed
self._conflicts = set(conflict_executions or set())
self._permanent = set(permanent_executions or set())
# dcy-018: optional authoritative cash/quantity returned on the outcome
# (simulates portfolio-hub#8 option D response). None => write-back skip.
self._cash_after = cash_after
self._quantity_after = quantity_after
def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult:
def record_trade(self, trade: LedgerTrade) -> LedgerWriteOutcome:
with self._lock:
if trade.execution_id in self._conflicts:
raise LedgerConflictError(
@@ -55,10 +62,16 @@ class InMemoryLedger:
raise LedgerRetryableError("simulated transient failure")
if trade.execution_id in self._trades:
# idempotent: same execution_id => no new entry, report dedup.
return LedgerWriteResult.DUPLICATE_OK
self._trades[trade.execution_id] = trade
self._order.append(trade)
return LedgerWriteResult.NEW
result = LedgerWriteResult.DUPLICATE_OK
else:
self._trades[trade.execution_id] = trade
self._order.append(trade)
result = LedgerWriteResult.NEW
return LedgerWriteOutcome(
result=result,
cash_balance_after=self._cash_after,
asset_quantity_after=self._quantity_after,
)
# --- introspection for tests ---
def trades(self) -> list[LedgerTrade]:
+25 -4
View File
@@ -26,9 +26,20 @@ from trex_core.application.ledger_port import (
LedgerPermanentError,
LedgerRetryableError,
LedgerTrade,
LedgerWriteOutcome,
LedgerWriteResult,
)
def _opt_num(v):
"""Parse an optional numeric (cash/quantity) field; None if absent/invalid."""
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
# dcy-015 / REQ-017: client errors that retrying cannot resolve.
_PERMANENT_STATUS = frozenset({400, 404, 422})
@@ -75,7 +86,7 @@ class PortfolioHubLedger:
return httpx.post(url, json=json, headers=self._headers(),
timeout=self._timeout)
def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult:
def record_trade(self, trade: LedgerTrade) -> LedgerWriteOutcome:
url = f"{self._base_url}/v1/portfolios/{trade.asset_id}/ledger/trades"
try:
resp = self._post(url, json=self._payload(trade))
@@ -89,9 +100,19 @@ class PortfolioHubLedger:
body = resp.json() if resp.content else {}
except ValueError:
body = {}
if isinstance(body, dict) and body.get("duplicate") is True:
return LedgerWriteResult.DUPLICATE_OK
return LedgerWriteResult.NEW
if not isinstance(body, dict):
body = {}
result = (
LedgerWriteResult.DUPLICATE_OK if body.get("duplicate") is True
else LedgerWriteResult.NEW
)
# dcy-018 / REQ-017 (option D, portfolio-hub#8): root flat authoritative
# cash/quantity after the write — present on new(201) + duplicate(200).
return LedgerWriteOutcome(
result=result,
cash_balance_after=_opt_num(body.get("cash_balance_after")),
asset_quantity_after=_opt_num(body.get("asset_quantity_after")),
)
if resp.status_code == 409:
raise LedgerConflictError(
f"portfolio-hub 409 conflict for execution_id={trade.execution_id}: "