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:
@@ -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]:
|
||||
|
||||
@@ -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}: "
|
||||
|
||||
@@ -369,6 +369,20 @@ class PostgreSQLLedgerOutbox:
|
||||
)
|
||||
return [self._row_to_item(r) for r in cur.fetchall()]
|
||||
|
||||
def pending_cash_delta(self) -> dict[str, float]:
|
||||
# dcy-018 / REQ-029 (OQ-4): estimated cash delta of all pending rows by
|
||||
# asset_id. SUM in SQL: sell → +(qty*price - fee - tax), buy → negative.
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT asset_id, "
|
||||
"SUM(CASE WHEN side='sell' "
|
||||
" THEN (qty*price - fee - tax) "
|
||||
" ELSE -(qty*price + fee + tax) END) "
|
||||
"FROM ledger_outbox WHERE status='pending' GROUP BY asset_id"
|
||||
)
|
||||
return {r[0]: float(r[1]) for r in cur.fetchall() if r[1] is not None}
|
||||
|
||||
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
Reference in New Issue
Block a user