diff --git a/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/design.md b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/design.md new file mode 100644 index 0000000..0e0bb87 --- /dev/null +++ b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/design.md @@ -0,0 +1,42 @@ +# BAT-023 Design + +- Status: confirmed +- Batch: BAT-023 + +## 설계 목표 +- ledger write 응답의 portfolio-hub 권위 cash/quantity 를 스냅샷에 write-back 해 "수량 live·현금 stale" 비대칭을 제거(option D 활성화). 공백 구간은 추정 현금으로 메우되 안전·멱등·best-effort 를 보장. + +## 1. 응답 계약 확장 (REQ-017) +- `LedgerWriteOutcome{result: LedgerWriteResult, cash_balance_after: Optional[float], asset_quantity_after: Optional[float]}` 신설. `record_trade` 반환 타입 변경. +- `portfolio_hub.py`: 2xx 응답 root flat `cash_balance_after`/`asset_quantity_after` 파싱(`_opt_num`, 누락/형변환 실패 시 None). new(201)/duplicate(200) 모두. 409→Conflict, 404/400/422→Permanent(dcy-015) 예외 유지. +- `in_memory.py`: `cash_after`/`quantity_after` 주입 파라미터 → outcome 에 실어 반환(테스트용). + +## 2. write-back (REQ-019/030, option D) +- `LedgerWriter(snapshot_store=None)` 주입. `_process` 가 `new`·`duplicate` outcome 후 `_writeback(trade, outcome)` 호출. +- `_writeback`: snapshot_store None 또는 `*_after` None 이면 skip. 아니면 `update_cash_position(trade.asset_id, cash_after, qty_after)`. **try/except 로 격리**(실패는 WARN, ledger outcome·청산 무차단, NFR-1). +- duplicate 도 write-back(OQ-2 b): `*_after` 가 절대 권위값이라 멱등(중복 적용 위험 없음, NFR-3). 409/422 는 예외라 자연 skip. + +## 3. 추정 현금 오버레이 (REQ-029 / OQ-4) +- source = **미정산 outbox delta**(슬롯 아님): 슬롯 기반이면 write-back 후에도 슬롯이 holding 이라 double-count. 미정산(pending) outbox 행은 정확히 "fill∼write-back 공백" 집합. +- `trade_cash_delta(trade)`: buy → -(qty*price + fee + tax), sell → +(qty*price - fee - tax)(코어 근사). +- `LedgerOutbox.pending_cash_delta() -> {asset_id: Σdelta}`(InMemory: pending 행 합 / PG: SQL SUM CASE). 백오프 무관 전체 pending. +- `_apply_cash_estimate(payload, ledger_outbox)`(snapshot 핸들러): pending delta 있는 자산에 `cash_balance += delta` + `cash_estimated:true`. best-effort(예외 시 응답 불변). +- write-back 완료 → 행이 pending 이탈 → delta 0 → 추정 사라지고 store 는 권위값. 자연 수렴, double-count 없음. + +## 4. 수량 source (REQ-029, OQ-1 B) +- 슬롯 오버레이(`build_snapshot_envelope`) **변경 없음** — lifecycle 중 슬롯이 코어 권위 즉시값(불변식 A). write-back 은 현금 + 슬롯 밖 base 수량을 신선화. 슬롯↔write-back 불일치는 REQ-031 포지션 정합 경고로 노출(숨기지 않음). + +## 5. daemon 배선 +- 생성 순서: `SnapshotStore` 를 `LedgerWriter` 보다 먼저 생성(write-back target). +- `LedgerWriter(snapshot_store=self.snapshot_store)`, snapshot 핸들러 `ledger_outbox=self.outbox`. + +## 6. baseline 문서 (REQ-030 정합 포함) +- interface-contract: `LedgerWriteOutcome`/`*_after`/write-back/envelope `cash_estimated`. +- runtime-flows: ledger writer write-back 단계 + snapshot 응답 추정 오버레이. +- REQ-030 Status Approved→Implemented(파일 + srs/index register). + +## 테스트 설계 +- adapter: `*_after` 파싱(new/duplicate/누락). +- writer: write-back new/duplicate/skip(None)/best-effort(store 예외)/store 미주입; pending_cash_delta 합; estimate overlay + write-back 후 수렴. +- PG 게이트: `pending_cash_delta` SQL. +- 회귀: record_trade 반환 타입 변경에 따른 기존 ledger 테스트(outcome.result) 정합 — 기존 테스트 더블/소비처 전환. diff --git a/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/implementation.md b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/implementation.md new file mode 100644 index 0000000..c6b4f9c --- /dev/null +++ b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/implementation.md @@ -0,0 +1,41 @@ +# BAT-023 Implementation + +- Status: completed +- Batch: BAT-023 + +## 구현 요약 +- dcy-018 option D 활성화(ledger write 응답 cash/quantity write-back) + 추정 현금 오버레이를 전부 change-req 로 구현(REQ-030 Approved→Implemented). + +## 변경 내역 + +### REQ-017 — 응답 계약 확장 +- `trex_core/application/ledger_port.py`: `LedgerWriteOutcome{result, cash_balance_after?, asset_quantity_after?}` 신설; `record_trade` 반환 타입 → `LedgerWriteOutcome`. +- `trex_core/adapters/ledger/portfolio_hub.py`: 2xx 응답 root flat `cash_balance_after`/`asset_quantity_after` 파싱(`_opt_num`, new+duplicate, 누락 None). 409/404/400/422 예외 유지. +- `trex_core/adapters/ledger/in_memory.py`: `cash_after`/`quantity_after` 주입 + outcome 반환. + +### REQ-019/030 — write-back + 추정 source +- `trex_core/application/ledger_outbox.py`: `LedgerWriter(snapshot_store=None)` + `_writeback`(new+duplicate, `*_after` None/no-store skip, try/except best-effort); `_process` 가 outcome.result 소비. `trade_cash_delta` + `LedgerOutbox.pending_cash_delta()`(InMemory pending 합). +- `trex_core/adapters/persistence/postgresql.py`: `pending_cash_delta` SQL(SUM CASE by asset_id, status='pending'). +- `trex_core/runtime/daemon.py`: SnapshotStore 를 LedgerWriter 보다 먼저 생성 + `snapshot_store` 주입. + +### REQ-029 — 추정 오버레이 + 슬롯 오버레이 유지 +- `trex_core/application/messaging_handlers.py`: `_apply_cash_estimate(payload, ledger_outbox)` — 미정산 자산에 `cash_balance += pending delta` + `cash_estimated:true`(best-effort). snapshot 핸들러 `ledger_outbox` 파라미터. 슬롯 오버레이(`build_snapshot_envelope`)는 불변. +- `trex_core/runtime/daemon.py`: snapshot 핸들러에 `ledger_outbox=self.outbox` 배선. + +### REQ-030 — Approved→Implemented +- `docs/srs/Integration/req-030_*.md` Status Approved→Implemented + CHG-20260608-01; `docs/srs/index.md` register REQ-030 → Implemented. + +### 문서 +- `docs/interface-contract.md`: LedgerWriteOutcome/`*_after`/write-back/envelope `cash_estimated`. Last Updated → BAT-023. +- `docs/runtime-flows.md`: ledger writer write-back 단계 + snapshot 추정 오버레이. Last Updated → BAT-023. + +## 테스트 +- `tests/adapters/ledger/test_portfolio_hub_ledger.py` 확장: `*_after` 파싱(new/duplicate/누락) 3건. +- 신규 `tests/application/test_ledger_option_d.py`: write-back(new/duplicate/skip/best-effort/no-store) + pending_cash_delta + estimate overlay + write-back 후 수렴 (11건). +- 신규 PG 게이트 `tests/adapters/persistence/test_postgresql_ledger_outbox.py::test_pending_cash_delta_on_pg`. +- 회귀: `test_ledger_outbox_backoff.py` programmable ledger → LedgerWriteOutcome 반환 전환. + +## 검증 실행 +- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `350 passed / 35 skipped`(`::test_sandbox_token_and_submit` deselect — LIVE `.env` 환경 의존, 본 batch 무관). +- 인프라 게이트: `TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) TREX_RABBITMQ_TEST_URL=... pytest -q` → `385 passed`(pending_cash_delta SQL 포함). +- `ruff check trex_core tests` → All checks passed. diff --git a/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/index.md b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/index.md new file mode 100644 index 0000000..b49f764 --- /dev/null +++ b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/index.md @@ -0,0 +1,34 @@ +# BAT-023: ledger write 응답 cash/position write-back (option D 활성화) + 추정 현금 오버레이 + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-018 +- Included REQ: REQ-017 (CHG-20260608-02), REQ-019 (CHG-20260608-02), REQ-029 (CHG-20260608-01), REQ-030 (CHG-20260608-01, Approved→Implemented) + +## Summary +- dcy-018(ledger write 응답의 cash/quantity 를 portfolio 스냅샷에 write-back — option D 활성화 + 수량 source 통일, 운영 환류)을 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-018 OQ-3 대로 **전부 change-req, 신규 REQ 없음**(REQ-030 은 Approved→Implemented). +- 핵심: portfolio-hub#8(배포·동작 중)의 ledger write 응답 root flat `cash_balance_after`/`asset_quantity_after`(절대 권위값)를 어댑터가 파싱(REQ-017) → `LedgerWriter` 가 `new`·`duplicate` 에 `SnapshotStore.update_cash_position` write-back(REQ-019/030, best-effort). "수량 live·현금 stale" 비대칭 제거. 체결~write-back 공백은 미정산 outbox delta 기반 **추정 현금 오버레이**(`cash_estimated` 라벨, REQ-029/OQ-4)로 메우고 권위값 도착 시 수렴. + +## Scope +- In Scope: + - `record_trade` → `LedgerWriteOutcome{result, *_after}` + 어댑터 `*_after` 파싱(new+duplicate, 누락 None) (REQ-017) + - `LedgerWriter` SnapshotStore write-back(new+duplicate, best-effort) + `pending_cash_delta` (REQ-019) + - 추정 현금 오버레이(`_apply_cash_estimate`, `cash_estimated` 플래그) + 슬롯 오버레이 유지 (REQ-029, OQ-1 B/OQ-4 i) + - option D 활성화 → REQ-030 Approved→Implemented + - interface-contract/runtime-flows 정합 +- Out of Scope: + - portfolio-hub#8 자체(외부, 배포·동작 중) + - 일일 스냅샷 스케줄러 fetch 재설계(REQ-030 AC-1 유지) + - 슬롯 오버레이 제거(OQ-1 B 로 유지 확정) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- Source discovery 단일(dcy-018). OQ-1 B / OQ-2 b(new+duplicate write-back) / OQ-3 a(change-req) / OQ-4 (i)(추정 오버레이) Answered. DATA(응답 스키마)=dcy-005 A-7 `*_after` + portfolio-hub#8 동작 중. +- best-effort 격리(NFR-1): write-back·추정 모두 예외 격리, ledger 멱등(REQ-019)·청산 무차단. +- 멱등(NFR-3): `*_after` 가 절대 권위값이라 duplicate write-back 도 중복 적용 위험 없음. +- 추정(OQ-4)은 코어 근사 수수료라 "추정/미정산" 라벨 필수, 노출 구간은 outbox drain(~1s)으로 짧음. diff --git a/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/planning.md b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/planning.md new file mode 100644 index 0000000..293fda2 --- /dev/null +++ b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/planning.md @@ -0,0 +1,47 @@ +# BAT-023 Planning + +- Status: confirmed +- Batch: BAT-023 +- Profile: standard + +## Included REQ +- REQ-017 (Integration, change-req): `record_trade`→`LedgerWriteOutcome` + 어댑터 `*_after` 파싱. +- REQ-019 (Exception, change-req): `LedgerWriter` write-back + `pending_cash_delta` + SnapshotStore 주입. +- REQ-029 (Interface, change-req): 추정 현금 오버레이 + 슬롯 오버레이 유지. +- REQ-030 (Integration, change-req): option D 활성화 → Approved→Implemented. + +## Delivery Plan +1. REQ-017: `ledger_port.py` `LedgerWriteOutcome`; `portfolio_hub.py` `*_after` 파싱(new+duplicate); `in_memory.py` outcome 반환 + cash/qty 주입. +2. REQ-019/030: `ledger_outbox.py` LedgerWriter `snapshot_store` 주입 + `_writeback`(new+duplicate, best-effort) + `trade_cash_delta`/`pending_cash_delta`; `postgresql.py` `pending_cash_delta` SQL; daemon SnapshotStore 배선(생성 순서 조정). +3. REQ-029: `messaging_handlers.py` `_apply_cash_estimate`(snapshot 핸들러, ledger_outbox 주입); 슬롯 오버레이 불변; daemon ledger_outbox 배선. +4. 문서: interface-contract(LedgerWriteOutcome·*_after·cash_estimated)/runtime-flows(write-back·추정 flow). REQ-030 Status Approved→Implemented(파일+register). +5. 테스트 + 전체 회귀 + ruff + 인프라 게이트(PG pending_cash_delta). + +## Dependencies +- Internal: + - dcy-004/BAT-006 LedgerPort/outbox/writer(REQ-017/019) — 변경 대상. + - dcy-005/BAT-008 SnapshotStore/update_cash_position/envelope(REQ-029/030) — write-back sink + 오버레이. +- External: + - portfolio-hub#8 ledger write 응답 `*_after`(배포·동작 중, dcy-005 A-7). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (a) `record_trade` 반환 타입 변경(LedgerWriteResult→LedgerWriteOutcome)은 LedgerPort·두 어댑터·LedgerWriter·테스트 더블에 교차 — 계약 확장 경계를 설계에서 고정. (b) write-back best-effort 격리(실패가 ledger 멱등·청산 무차단)와 멱등(절대 *_after, duplicate 안전) 규칙을 못박아야 안전. (c) 추정 현금 source = 미정산 outbox delta(슬롯 아님 — 청산 후 double-count 방지) + 슬롯 오버레이 유지 경계 + daemon 생성 순서(SnapshotStore before LedgerWriter). → design.md 동일 턴 scaffold. + +## Milestones +1. M1 — REQ-017 LedgerWriteOutcome + 어댑터 파싱, adapter 테스트 green +2. M2 — REQ-019/030 write-back + pending_cash_delta, option D 테스트 green +3. M3 — REQ-029 추정 오버레이 + daemon 배선 +4. M4 — 문서 정합 + REQ-030 Implemented 전환 +5. M5 — 전체 회귀 + ruff + 인프라 게이트 + +## Risks +- R-1 (High→완화): write-back/추정이 ledger·청산을 막음. 대응: best-effort 예외 격리(NFR-1), write 성공 이후 분리 수행. source of truth(outbox 멱등) 불변. +- R-2 (해소): 응답 스키마 미확정. 대응: `*_after` dcy-005 A-7 확정 + portfolio-hub#8 동작 중. 누락 시 None→skip. +- R-3 (Low): 추정 부정확(근사 수수료). 대응: "추정/미정산" 라벨 + outbox drain(~1s) 짧은 구간 + write-back 수렴. +- R-4 (Medium): duplicate 중복 적용. 대응: `*_after` 절대값이라 멱등(덮어쓰기). + +## Notes +- 1차 게이트: 전체 회귀 green(record_trade 반환 타입 변경 호출부 전환 무누락). +- 추정/write-back 결정론: InMemoryLedger(cash_after/quantity_after 주입) + InMemoryLedgerOutbox + SnapshotStore. +- PG `pending_cash_delta` 는 인프라 게이트로 검증. diff --git a/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/verification.md b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/verification.md new file mode 100644 index 0000000..57e9b55 --- /dev/null +++ b/docs/batches/bat-023_20260608_ledger-cash-position-writeback-option-d/verification.md @@ -0,0 +1,59 @@ +# BAT-023 Verification + +- Status: verified +- Batch: BAT-023 + +## Acceptance Mapping + +### REQ-017 (응답 계약 확장, change-req) +| AC | Evidence | +|----|----------| +| record_trade → LedgerWriteOutcome + `*_after`(new) | tests/adapters/ledger/test_portfolio_hub_ledger.py::test_outcome_parses_cash_quantity_after_on_new | +| duplicate `*_after` | ::test_outcome_parses_after_on_duplicate | +| 응답 누락 → None | ::test_outcome_after_none_when_response_omits | +| 409/404/422 예외 회귀 | ::test_409_surfaces_as_ledger_conflict_error / ::test_client_errors_are_permanent | + +### REQ-019 (write-back + pending_cash_delta, change-req) +| AC | Evidence | +|----|----------| +| new write-back | tests/application/test_ledger_option_d.py::test_writeback_updates_snapshot_on_new | +| duplicate write-back(멱등) | ::test_writeback_applies_on_duplicate_too | +| `*_after` 누락 시 skip | ::test_writeback_skipped_when_after_missing | +| best-effort(store 예외 무차단) | ::test_writeback_best_effort_does_not_block_completion | +| store 미주입 무크래시 | ::test_no_writeback_without_store | +| pending_cash_delta 합(InMemory/PG) | ::test_pending_cash_delta_sums_by_asset / test_postgresql_ledger_outbox.py::test_pending_cash_delta_on_pg | + +### REQ-029 (추정 오버레이 + 슬롯 오버레이 유지, change-req) +| AC | Evidence | +|----|----------| +| 미정산 자산 추정 cash + cash_estimated | tests/application/test_ledger_option_d.py::test_apply_cash_estimate_overlays_pending_delta | +| write-back 후 수렴(추정 제거) | ::test_estimate_clears_after_writeback | +| 슬롯 오버레이 불변 회귀 | tests/application/test_messaging_snapshot_consumer.py 전체 | + +### REQ-030 (option D Approved→Implemented, change-req) +| AC | Evidence | +|----|----------| +| AC-2/AC-3 deferred 해소(ledger write `*_after` → SnapshotStore) | test_ledger_option_d.py(write-back 전반) + REQ-017/019 evidence | +| AC-1 baseline 스케줄러 회귀 | tests/application/test_preallocation_scheduler.py + snapshot scheduler 회귀 | + +## Evidence +- Test: + - 단위 전체 회귀: `350 passed / 35 skipped`(신규 option D 11 + adapter 3 포함; `::test_sandbox_token_and_submit` deselect). + - 인프라 게이트(PG trex_core_test + RabbitMQ): `385 passed`(pending_cash_delta SQL 포함). + - `ruff check trex_core tests` → All checks passed. +- Logs: `350 passed, 35 skipped, 1 deselected` / `385 passed, 1 deselected ~17.9s`. + +## 성공 기준(dcy-018) 대응 +- S-1(매수/매도 후 cash+quantity 모두 권위값 갱신): test_writeback_updates_snapshot_on_new(+duplicate). 수량은 슬롯 오버레이(유지) + write-back base 신선화. +- S-2(portfolio-hub 응답 누락/불가 시 skip + ledger 정상): test_writeback_skipped_when_after_missing + test_writeback_best_effort_does_not_block_completion. +- S-3(DUPLICATE 중복 적용 없음): test_writeback_applies_on_duplicate_too(절대 권위값 멱등). +- (OQ-4) 추정 현금: test_apply_cash_estimate_overlays_pending_delta + test_estimate_clears_after_writeback. + +## Carried-Over / 비차단 +- 실 데몬 + portfolio-hub#8 full-stack option D e2e(실 `*_after` 응답 → 스냅샷 갱신)는 운영 배포 검증(REL-005 등)에서 확인 — 단위는 InMemoryLedger `*_after` 주입 + adapter 파싱으로 커버. +- 추정 현금은 코어 근사 수수료 기반 — "추정/미정산" 라벨로 커버, write-back 수렴(짧은 구간). + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. option D write-back·추정 오버레이·REQ-030 전환 모두 단위 green(385 passed 회귀, record_trade 반환 타입 변경 호출부 전환 무누락). LIVE/실 portfolio-hub#8 full-stack 은 운영 배포 검증 비차단. diff --git a/docs/batches/index.md b/docs/batches/index.md index 7b413fe..f58e13e 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -30,3 +30,4 @@ | BAT-020 | standard | release-candidate | REQ-032 (CHG-20260608-01), REQ-022 (CHG-20260608-01), REQ-021 (CHG-20260608-02), REQ-034 (CHG-20260608-01), REQ-026 (CHG-20260608-01) | dcy-016 | [bat-020](./bat-020_20260608_operator-cli-rpc-commands/) | | BAT-021 | standard | release-candidate | REQ-034 (CHG-20260608-02), REQ-021 (CHG-20260608-03), REQ-037 (CHG-20260608-01), REQ-038 (CHG-20260608-01), REQ-039 (CHG-20260608-01), REQ-032 (CHG-20260608-02), REQ-042 (CHG-20260608-01) | dcy-017 | [bat-021](./bat-021_20260608_watchdog-ownership-visibility/) | | BAT-022 | standard | release-candidate | REQ-028 (CHG-20260608-01), REQ-032 (CHG-20260608-03) | dcy-016 | [bat-022](./bat-022_20260608_slot-state-snapshot-visibility/) | +| BAT-023 | standard | release-candidate | REQ-017 (CHG-20260608-02), REQ-019 (CHG-20260608-02), REQ-029 (CHG-20260608-01), REQ-030 (CHG-20260608-01, Approved→Implemented) | dcy-018 | [bat-023](./bat-023_20260608_ledger-cash-position-writeback-option-d/) | diff --git a/docs/discovery/dcy-018_20260608_ledger-response-cash-position-writeback.md b/docs/discovery/dcy-018_20260608_ledger-response-cash-position-writeback.md new file mode 100644 index 0000000..65d9bea --- /dev/null +++ b/docs/discovery/dcy-018_20260608_ledger-response-cash-position-writeback.md @@ -0,0 +1,233 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-018_20260608_ledger-response-cash-position-writeback +- 이슈명: ledger write 응답의 cash/quantity 를 portfolio 스냅샷에 write-back — option D 활성화 + 수량 source 통일 +- 작성 시각(KST): 2026-06-08 20:08 KST +- 마지막 갱신 시각(KST): 2026-06-08 21:45 KST +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-017/019/029 change-req + REQ-030 Approved→Implemented (전부 change-req, 신규 REQ 없음 — OQ-3 a). REQ-017/019=CHG-20260608-02, REQ-029/030=CHG-20260608-01. delivery: BAT-023 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: 운영 관측(매수 후 universe `asset_quantity` 는 1로 바뀌나 `cash_balance` 는 불변), portfolio-hub#8(ledger 응답에 cash/quantity 반환) 요청 +- 해석: + - 의도된 설계는 "ledger write → portfolio-hub 응답(cash/quantity) → trex-core SnapshotStore 갱신"(REQ-030 option D)이다. 그런데 trex-core 측에서 응답 파싱·write-back 배선이 누락되어, 현금은 일일 스냅샷 전까지 반영되지 않고 수량만 슬롯 오버레이로 살아있어 보이는 비대칭이 발생한다. + - 이번 Discovery에서는 (1) ledger write 응답의 cash/quantity 를 파싱·운반, (2) SnapshotStore write-back 배선(option D 활성화), (3) 수량 source 를 portfolio-hub 권위값으로 통일(슬롯 오버레이 정합)하는 범위를 판정한다. portfolio-hub#8 응답 계약 의존과 거버넌스도 함께 정한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - REQ-030(option D 현금/포지션 갱신)은 `Approved` 상태로 "wired but no-op until portfolio-hub#8 ships" 로 연기되어 구현되지 않았다. 본 Discovery 는 그 연기된 경로를 실제 활성화하고, ledger 응답 파싱(REQ-017)·write-back 배선(REQ-019)·수량 source 통일(REQ-029)을 함께 다룬다. + - dcy-005(snapshot read)는 option D 를 정의했으나 portfolio-hub#8 대기로 미구현 상태로 남겼다. 운영 환류로 갭이 확인되어 독립 주기로 활성화·정합하는 것이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - **(1) ledger 응답 폐기**: `trex_core/adapters/ledger/portfolio_hub.py` 의 `record_trade`(portfolio_hub.py:78~94)는 2xx 응답에서 `resp.json()` 을 읽지만 **오직 `duplicate` 플래그만** 보고 `LedgerWriteResult.NEW`/`DUPLICATE_OK` 만 반환한다. portfolio-hub 가 cash/quantity 를 응답에 실어도 추출·반환하지 않는다. + - **(2) write-back 배선 부재**: `trex_core/application/ledger_outbox.py` 의 `LedgerWriter`(ledger_outbox.py:132~)는 `SnapshotStore` 참조가 없어, 응답에 값이 있어도 갱신할 경로가 없다. + - **(3) sink 만 존재, 호출자 없음**: `trex_core/application/portfolio.py` 의 `SnapshotStore.update_cash_position`(portfolio.py:148~)은 cash_balance·asset_quantity 를 쓰는 메서드로 구현돼 있으나, ledger 경로에서 호출되지 않는다(snapshot_scheduler 의 코멘트만 참조). + - **수량 비대칭(슬롯 오버레이)**: `build_snapshot_envelope`(portfolio.py:426~427)는 universe 의 `asset_quantity` 를 **로컬 슬롯 holding qty 로 override**(REQ-029)한다. 그래서 매수 직후 수량은 슬롯에서 살아있게 보이지만 portfolio-hub 권위값이 아니고, 현금은 오버레이가 없어 옛 스냅샷 값 그대로다. + - **option D 미구현**: REQ-030(option D 현금/포지션 갱신)은 `Approved`(미구현). `snapshot_scheduler.py:5~6` 주석 — "wired through `SnapshotStore.update_cash_position` but disabled at the ledger layer until portfolio-hub#8 ships". + - baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`, `docs/data-model.md`)는 존재하며 최신이다. ledger 기록·스냅샷 read 는 기존 runtime flow 에 속한다. +- 기대 상태 + - ledger NEW write 후 portfolio-hub 응답의 **cash/quantity 가 SnapshotStore 에 write-back** 되어, 매수/매도 직후 `cash_balance` 와 `asset_quantity` 가 모두 portfolio-hub 권위값으로 갱신된다. + - 수량과 현금이 **동일 source(portfolio-hub)** 로 통일되어, "수량은 살아있고 현금은 stale" 한 비대칭이 사라진다. + - portfolio-hub 불가/응답 누락 시에도 best-effort 로 degrade 하며 ledger 기록·청산 동작을 막지 않는다. +- 이해관계자 + - 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 작성 + - 리뷰어/승인자 — ledger 응답 계약·write-back·수량 source 변경 승인 + - 운영 담당자 — 실시간 현금/포지션 정합 관측 + - portfolio-hub 팀 — ledger write 응답에 cash/quantity 반환(portfolio-hub#8) 제공 측 +- 제약사항 + - ledger write 응답의 cash/quantity 필드 계약은 portfolio-hub#8 배포·응답 스키마에 의존한다(외부 의존). + - write-back 부수효과가 ledger 기록 멱등(REQ-019)·청산 동작을 깨지 않아야 한다(best-effort 격리). + - 수량 source 변경(슬롯 오버레이 → portfolio-hub)은 매수~ledger 반영 사이의 타이밍 공백 처리가 필요하다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 운영자가 `trex-core enter` 로 매수 후 `trex-core universe` 를 보면, `asset_quantity` 와 `cash_balance` 가 **모두** portfolio-hub 권위값으로 갱신되어 있다(현재는 수량만). +- 매도 후에도 동일하게 현금 증가·수량 감소가 즉시 스냅샷에 반영된다. +- portfolio-hub 가 일시 불가하면 cash/quantity 가 stale 임을 degraded 로 신호하고, ledger 기록은 outbox 로 계속 진행된다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- ledger 어댑터 `record_trade` 가 portfolio-hub 응답의 cash/quantity 를 파싱·운반하도록 결과 타입을 확장한다(현재 `LedgerWriteResult` 만 → cash/quantity 동반). +- `LedgerWriter` 에 `SnapshotStore` 를 주입하고, `new`(201)·`duplicate`(200) 응답의 `cash_balance_after`/`asset_quantity_after`(절대 권위값) 로 `update_cash_position(asset_id, cash, quantity)` write-back 한다(option D 활성화, best-effort; 409/422 는 `*_after` 미정의라 skip — OQ-2 b). +- 수량/현금 source 정합(OQ-1 B): **슬롯 오버레이(REQ-029)는 유지**(lifecycle 중인 슬롯은 코어 권위 즉시값, 불변식 A), write-back 은 **현금 + 슬롯 밖(미보유) base 수량**을 portfolio-hub 권위값으로 신선화. 비대칭(수량 live·현금 stale) 제거. +- 체결~write-back 공백 동안 **현금을 추정 오버레이로 즉시 표시**(OQ-4 (i)): base 권위 현금 ± 미정산 체결 cash_delta(체결가×수량 ∓ 수수료/세금), **"추정/미정산" 라벨 필수**, write-back 권위값 도착 시 교체. +- 응답 계약·write-back·수량/현금 정합을 baseline 문서(interface-contract/runtime-flows)에 반영한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `trex_core/adapters/ledger/portfolio_hub.py` — `record_trade` 응답 파싱(cash/quantity) + 결과 타입 확장 + - `trex_core/application/ledger_outbox.py` — `LedgerWriter` 에 `SnapshotStore` 주입 + NEW write 후 `update_cash_position` 호출(best-effort) + - `trex_core/application/portfolio.py` — `update_cash_position` 활용, 수량 source 정합(`build_snapshot_envelope` 의 슬롯 오버레이 처리) + - `trex_core/runtime/daemon.py` — LedgerWriter 에 SnapshotStore 배선 +- 문서 영향: + - `docs/interface-contract.md` — ledger write 응답 계약(cash/quantity) + 스냅샷 cash/quantity source 반영 + - `docs/runtime-flows.md` — ledger write → 응답 → SnapshotStore write-back 흐름 추가 +- 운영 영향: + - 매매 직후 실시간 현금/포지션 정합 → 운영 관측 정확도 향상 + - portfolio-hub#8 응답 계약 의존 — 배포 조율 필요 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: `record_trade` 는 portfolio-hub ledger write 응답 root 의 flat 필드 **`cash_balance_after`/`asset_quantity_after`**(절대 권위값)를 파싱해 운반한다(결과 타입 확장, `new`/`duplicate` 구분 유지). status별: `new`(201)·`duplicate`(200) = 권위값 있음, 409·422 = `*_after` 미정의(dcy-005 A-7 / portfolio-hub#8 확정·동작 중). +- FR-2: `LedgerWriter` 는 `new`·`duplicate` write 성공 시 `SnapshotStore.update_cash_position(asset_id, cash_balance_after, asset_quantity_after)` 로 write-back 한다(OQ-2 b — `*_after`가 delta 아닌 절대 권위값이라 덮어써도 멱등). 409/422 는 skip. 부수효과는 best-effort — 실패가 ledger 기록 멱등(REQ-019)·청산을 막지 않는다(write 성공 *이후* 분리 수행, 예외 무시). +- FR-3: 수량 source 정합(OQ-1 B) — 슬롯 오버레이(REQ-029)는 **유지**(lifecycle 중 슬롯은 코어 권위 즉시값, 불변식 A). write-back 은 **현금 + 슬롯 밖 base 수량**을 portfolio-hub 권위값으로 신선화. 슬롯 권위와 write-back 권위가 어긋나면 숨기지 않고 포지션 정합 경고(REQ-031)로 노출. +- FR-3a: 임시 현금 추정 오버레이(OQ-4 (i)) — 체결~write-back 공백 동안 `base 권위 현금 ± 미정산 체결 cash_delta`(체결가×수량 ∓ 수수료/세금)를 **"추정/미정산" 라벨과 함께** 노출하고, write-back 권위값 도착 시 교체·미정산 delta 제거. 코어 수수료(`calculate_fee`)는 근사라 추정 오차는 라벨로 커버. +- FR-4: portfolio-hub 불가·응답 누락(또는 dead-letter, dcy-015) 시 권위 현금/수량을 갱신하지 못하므로 추정·pending 유지 + degraded(REQ-028)로 신호하고 write-back 을 건너뛴다(best-effort). +- FR-5: `docs/interface-contract.md`·`docs/runtime-flows.md` 에 응답 계약(`*_after`)·write-back 흐름·수량/현금 source·추정 오버레이를 반영한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: 안전 격리 — write-back 부수효과는 best-effort 이며, 어떤 실패도 ledger 기록 멱등(REQ-019)·청산 동작을 차단하지 않아야 한다. +- NFR-2: 권위 일관성 — cash 와 quantity 가 동일 source(portfolio-hub)에서 와 비대칭(수량 live·현금 stale)이 재발하지 않아야 한다. +- NFR-3: 멱등 정합 — DUPLICATE write 응답으로 인한 cash/quantity 중복 적용이 없어야 한다(멱등 write-back). + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - ledger write 응답 `cash_balance_after`/`asset_quantity_after` 파싱(`record_trade`) + write-back 배선(`LedgerWriter`→`update_cash_position`, `new`+`duplicate`) + - 수량/현금 source 정합(슬롯 오버레이 유지 + write-back 권위 신선화, OQ-1 B) + - 임시 현금 추정 오버레이(라벨, OQ-4 (i)) + write-back 권위 수렴 + - degraded/best-effort 처리 + 문서 갱신 + REQ-030 Approved→Implemented +- Out of Scope + - portfolio-hub#8 자체(외부 — 이미 배포·동작 중, 본 주기는 trex-core 소비 측) + - 일일 스냅샷 스케줄러 자체 재설계(REQ-030 의 fetch 부분은 유지) + - 슬롯 오버레이 제거(OQ-1 B 로 **유지** 확정 — 제거 안 함) + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): write-back 부수효과가 실패·지연하면 ledger 기록 루프·청산을 막을 수 있다. + - 완화 방안: FR-2 best-effort 격리(`RedisTokenCache`/dcy-017 패턴) — 예외를 no-op 무시, ledger 멱등·청산 동작 불변. write-back 은 write 성공 *이후* 분리 수행. +- R-2 (해소): portfolio-hub#8 응답 스키마 미확정 위험. + - 해소: 응답 스키마 = root flat `cash_balance_after`/`asset_quantity_after`(dcy-005 A-7 확정) + portfolio-hub#8 배포·동작 중(2026-06-08). 필드 부재(409/422) 시 write-back skip(보수). 잔여 위험 없음. +- R-3 (Low, OQ-1 B + OQ-4 로 완화): 매수~write-back 공백에 현금이 잠깐 stale 할 수 있다. + - 완화 방안: 수량은 슬롯 오버레이(정확값) 유지, 현금은 추정 오버레이(OQ-4 i, "추정/미정산" 라벨)로 즉시 표시 후 권위값 수렴. 공백 구간은 outbox drain(~1s)으로 짧다. +- R-4 (Medium): DUPLICATE write 응답에 cash/quantity 가 와도 중복 적용하면 정합이 깨진다. + - 완화 방안: NEW write 에만 write-back 적용(또는 멱등 키 기준), DUPLICATE 는 스킵(NFR-3). + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: portfolio-hub 가 ledger write 응답에 갱신된 cash/quantity 를 반환한다(portfolio-hub#8). 미배포 시 본 변경은 write-back 스킵(stale)으로 안전 동작하며, 배포 후 활성화된다. +- A-2: `SnapshotStore.update_cash_position` 이 이미 구현돼 있어 sink 는 재사용 가능하고, 누락된 것은 source(응답 파싱)와 배선(writer→store)뿐이다. +- A-3: ledger 기록 멱등(correlation_id, REQ-019)은 그대로 유지되며, write-back 은 그 위의 관측 갱신이다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 수량 source 통일 방식 — 슬롯 오버레이(REQ-029)를 제거하고 portfolio-hub write-back 값으로 일원화하는가, 아니면 슬롯 오버레이를 유지하고 write-back 으로 수렴하는가? + - 상태: Answered (2026-06-08, 사용자 결정 — B: 슬롯 오버레이 유지 + write-back 수렴) + - 결정: 슬롯 오버레이(REQ-029)는 **유지**한다 — lifecycle 중인 슬롯은 코어 자체 권위·즉시·외부 무관(불변식 A). portfolio-hub write-back 은 **현금과 슬롯 밖(미보유) base 수량**을 권위값으로 신선화해 비대칭(수량 live·현금 stale)을 제거한다. (A)는 매수 직후 공백에 수량을 옛값으로 후퇴시키고 portfolio-hub 불가 시 보유 수량이 0 으로 보이는 안전 리스크가 있어 미채택. + - 반영 위치: FR-3, 4. 변경 항목, 범위 경계 +- OQ-2: write-back 시점/실패 처리 — 어떤 응답에 write-back 하고, 불가·DUPLICATE·잔고부족 시 규칙은? + - 상태: Answered (2026-06-08, 사용자 결정 — b) + - 결정: `*_after` 가 delta 가 아니라 **절대 권위값**이므로 `new`(201)·`duplicate`(200) **둘 다 write-back**(덮어써도 멱등). 409·`422` 는 `*_after` 미정의라 **skip**. 시점은 LedgerWriter 가 ledger write 성공 응답을 받은 직후(별도 read round-trip 불요). write-back 부수효과는 best-effort — 예외/누락은 무시하고 ledger 멱등(REQ-019)·청산을 막지 않는다. + - 반영 위치: FR-2, FR-4, NFR-1/3 +- OQ-3: 거버넌스 경로 — change-req 로 처리할지, 신규 REQ 로 추적할지? + - 상태: Answered (2026-06-08, 사용자 결정 — a: 전부 change-req) + - 결정: 기존 Implemented/Approved REQ 본문 수정(change-req)으로 처리한다. 대상 — REQ-017(어댑터 `*_after` 응답 파싱 + 결과 타입 확장), REQ-019(`LedgerWriter` write-back 배선 + SnapshotStore 주입), REQ-029(수량 source 정합 — 슬롯 오버레이 유지 명시), **REQ-030(option D 활성화 — Approved→Implemented; REQ-030 Change Log Follow-up 의 "portfolio-hub#8 배포 후 minor-change 로 option D 구현" 예고대로)**. 신규 REQ 는 생성하지 않는다. (임시 현금 추정은 REQ-029/030 범위 내 관측 갱신으로 흡수.) + - 반영 위치: 1. 거버넌스 해석, 범위 경계 +- OQ-4: 체결~write-back 공백 동안 임시 현금 처리 — 추정값을 계산해 보여줄지(라벨), 아니면 마지막 권위값 + pending 플래그만 노출할지? + - 상태: Answered (2026-06-08, 사용자 결정 — (i) 추정 오버레이) + - 결정: 공백 동안 **추정 현금을 계산해 노출**한다 — `base 권위 현금 ± 미정산 체결 cash_delta`(체결가×수량 ∓ 수수료/세금), **"추정/미정산" 라벨 필수**. write-back 권위값(`cash_balance_after`) 도착 시 교체하고 해당 미정산 delta 를 제거한다. 코어 수수료(`calculate_fee`)는 근사라 추정 오차가 있으나 라벨로 커버하고, 추정 노출 구간은 outbox drain(~1s)으로 짧다. (수량은 슬롯 오버레이가 정확값이라 추정 불요 — OQ-1 B 와 짝.) + - 반영 위치: FR-3a, 4. 변경 항목, R-3 + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 매수/매도 후 universe 스냅샷의 `cash_balance` 와 `asset_quantity` 가 모두 portfolio-hub 응답 권위값으로 갱신된다(현재: 수량만 live, 현금 stale). +- S-2: portfolio-hub 응답에 cash/quantity 가 없거나 불가하면 write-back 을 스킵하고 ledger 기록·청산이 정상 동작한다(best-effort). +- S-3: DUPLICATE write 응답이 cash/quantity 를 중복 적용하지 않는다(멱등). + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: ledger write 응답 fixture(cash/quantity 포함) 주입 후 SnapshotStore 갱신·universe 출력 확인 / 데이터 출처: `record_trade`/`LedgerWriter` 테스트, universe 응답 +- S-2 측정 방식: 응답 누락/portfolio-hub 불가 주입 시 write-back 스킵·ledger 정상 확인 / 데이터 출처: 장애 주입 테스트, outbox 상태 +- S-3 측정 방식: 동일 execution_id 중복 write 시 cash/quantity 1회만 적용 확인 / 데이터 출처: 멱등 write-back 테스트 + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: 수량 source(OQ-1 B)·write-back 규칙(OQ-2 b)·임시 현금(OQ-4 i)·거버넌스(OQ-3 a) 확정으로 In/Out of Scope 채워짐 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-3 의 테스트/검증 출처가 실제 실행 가능한 형태로 지정됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(write-back best-effort 격리)·R-2(응답 스키마 = `*_after` 확정, portfolio-hub#8 동작 중)의 해소 방식과 change-req 추적(OQ-3 a)이 확정됨. 해소 실무 책임자 배정은 REQ/batch planning 단계로 넘김(비차단). +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1~OQ-4 모두 `Answered`, `Open` 0개 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Confirmed By = Justin (confirm-discovery 단계 지정) + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-018_20260608_ledger-response-cash-position-writeback.md +- 참조 문서: + - 운영 환류(매수 후 cash_balance 미반영 관측) + portfolio-hub#8 요청 + - docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md — REQ-029(슬롯 오버레이)/REQ-030(option D) 도입 근거 + - docs/discovery/dcy-004_20260604_portfolio-hub-ledger-integration.md — ledger 기록 경로 + - trex_core/adapters/ledger/portfolio_hub.py:78-94 — `record_trade` 응답 폐기 지점 + - trex_core/application/ledger_outbox.py:132 — `LedgerWriter`(SnapshotStore 부재) + - trex_core/application/portfolio.py:148, 426-427 — `update_cash_position`(호출자 없음)·슬롯 오버레이 + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +> 결정 완료(2026-06-08): OQ-1 수량 source = B(슬롯 오버레이 유지 + write-back 수렴) / OQ-2 write-back = b(`new`+`duplicate` 둘 다, 409·422 skip, best-effort) / OQ-3 거버넌스 = a(전부 change-req: REQ-017·019·029·030) / OQ-4 임시 현금 = (i) 추정 오버레이(라벨)+권위 수렴. DATA(portfolio-hub#8 응답 스키마)는 해소. + +## DECIDE +- (없음 — OQ-1/OQ-4 확정으로 수량/현금 source 해소) + +## CONFIRM +- (없음 — 거버넌스 경로 OQ-3 a 로 확정: REQ-017/019/029/030 change-req, 신규 REQ 없음) + +## DATA +- (해소) portfolio-hub#8 ledger write 응답 = root flat `cash_balance_after`/`asset_quantity_after`(절대 권위값), status별 `new`(201)/`duplicate`(200)=값 있음·409/422=미정의. dcy-005 A-7 에서 확정됐고 portfolio-hub#8 **배포·동작 중**(사용자 확인 2026-06-08) — "배포 대기" 조건부 해제됨. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: confirm-discovery 완료. 저장소 사실관계 일치 확인(record_trade 는 `duplicate`만 파싱·`*_after` 미파싱 / update_cash_position 정의만 있고 호출자 없음 / LedgerWriter 에 SnapshotStore 없음 / REQ-030 Approved). 오픈 질문 OQ-1~OQ-4 모두 Answered (수량 source=B 슬롯 오버레이 유지+write-back 수렴 / write-back=b `new`+`duplicate` 둘 다·409/422 skip / 거버넌스=a 전부 change-req REQ-017·019·029·030 / 임시 현금=(i) 추정 오버레이 라벨). DATA(portfolio-hub#8 응답 스키마)는 dcy-005 A-7 `cash_balance_after`/`asset_quantity_after` 확정 + portfolio-hub#8 배포·동작 중(사용자 확인)으로 해소. C 유형 플레이스홀더 0개. REQ 초안 작성 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-08 21:45 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 41633a0..9c9ee11 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -22,4 +22,5 @@ | dcy-014_20260608_trading-date-configurable-timezone-kst | 20260608 | 당일 슬롯 생성 기준 시간을 KST(설정 가능)로 분리 — trading_date 타임존 분리 + 일자 경계 preallocation (issue #14) | [Discovery](./dcy-014_20260608_trading-date-configurable-timezone-kst.md) | confirmed | | dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap | 20260608 | portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도됨 (issue #13) | [Discovery](./dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md) | confirmed | | dcy-016_20260608_operator-cli-rpc-commands | 20260608 | 운영자 CLI 명령 확장 — universe/슬롯 확인 + enter/exit + pause/resume (데몬 RPC 통일) | [Discovery](./dcy-016_20260608_operator-cli-rpc-commands.md) | confirmed | -| dcy-017_20260608_watchdog-ownership-visibility | 20260608 | 전략 이탈로 슬롯이 내부 청산 루틴(워치독) 소유로 넘어간 상태를 가시화 — CLI/RPC vs Redis projection (issue #12) | [Discovery](./dcy-017_20260608_watchdog-ownership-visibility.md) | confirmed | \ No newline at end of file +| dcy-017_20260608_watchdog-ownership-visibility | 20260608 | 전략 이탈로 슬롯이 내부 청산 루틴(워치독) 소유로 넘어간 상태를 가시화 — CLI/RPC vs Redis projection (issue #12) | [Discovery](./dcy-017_20260608_watchdog-ownership-visibility.md) | confirmed | +| dcy-018_20260608_ledger-response-cash-position-writeback | 20260608 | ledger write 응답의 cash/quantity 를 portfolio 스냅샷에 write-back — option D 활성화 + 수량 source 통일 (운영 환류) | [Discovery](./dcy-018_20260608_ledger-response-cash-position-writeback.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 5f8688f..8f42c9c 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-021) +- Last Updated (KST): 2026-06-08 (BAT-023) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -150,7 +150,8 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason "cash_balance": number, "asset_quantity": number, "active_strategy_id": str | null, - "strategy_params": object # opaque, pass-through + "strategy_params": object, # opaque, pass-through + "cash_estimated": true? # dcy-018/OQ-4 — cash_balance 가 미정산 추정값일 때만(라벨) }, ... ], "view": "all" | "buyable" | "held", # echo @@ -379,7 +380,7 @@ resume_kill_switch 요청·응답 (REQ-046): ### Inputs -- record_trade(trade: LedgerTrade) :: 단일 체결을 원장에 기록 (`execution_id` 멱등) +- record_trade(trade: LedgerTrade) :: 단일 체결을 원장에 기록 (`execution_id` 멱등) → `LedgerWriteOutcome` 반환 ### `LedgerTrade` Fields @@ -395,8 +396,12 @@ resume_kill_switch 요청·응답 (REQ-046): ### Outputs / Outcomes -- `LedgerWriteResult.NEW` :: 새 원장 엔트리 적재 -- `LedgerWriteResult.DUPLICATE_OK` :: 같은 execution_id 가 이미 기록되어 있음(서버 멱등) +- `LedgerWriteOutcome` (dcy-018/REQ-017) :: `{result, cash_balance_after?, asset_quantity_after?}` + - `result = LedgerWriteResult.NEW` :: 새 원장 엔트리 적재 + - `result = LedgerWriteResult.DUPLICATE_OK` :: 같은 execution_id 가 이미 기록되어 있음(서버 멱등) + - `cash_balance_after`/`asset_quantity_after` :: portfolio-hub#8 ledger write 응답 root flat 권위값(option D). `new`(201)·`duplicate`(200) 에 존재, 응답 누락 시 None(write-back skip). 409/422 는 예외(아래)라 outcome 미반환. +- write-back(REQ-019/030): `LedgerWriter` 가 `new`·`duplicate` outcome 의 `*_after`(None 아님)를 `SnapshotStore.update_cash_position(asset_id, cash, qty)` 로 반영(best-effort). 절대 권위값이라 duplicate 도 멱등. +- 스냅샷 응답 추정 현금(REQ-029/OQ-4): write-back 전 공백 동안 자산에 `cash_balance += 미정산 pending 합` + `cash_estimated: true` 플래그(라벨). write-back 도착 시 권위값으로 수렴(pending 제거). ### Error Contract diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 4cdacc3..e009123 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-021) +- Last Updated (KST): 2026-06-08 (BAT-023) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -129,6 +129,7 @@ - `strategy_key` 일치(`==`) 직교 필터. - degraded 상태가 있으면 `degraded=true + reason + sanitized message` 동봉. 4. envelope: `{assets[], view, available_slots, degraded, reason, message}` — `assets` 는 항상 배열, null 금지. 항목 필드는 `symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params`, **price 필드는 없음**. +5. 추정 현금 오버레이(dcy-018/REQ-029, OQ-4): 미정산(pending) ledger outbox 행이 있는 자산은 `cash_balance += pending cash delta`(매수 −, 매도 +) + `cash_estimated:true` 라벨로 노출 → option D write-back 도착 시 권위값으로 수렴(pending 제거). best-effort(실패 시 응답 불변). 수량은 슬롯 오버레이(REQ-029)가 정확값이라 추정 불요. ## Flow: `per-slot 워치독 갱신` (BAT-010/011 / REQ-034, REQ-040) @@ -244,9 +245,10 @@ 1. `LedgerOutbox.list_pending(limit)` 으로 pending work item 을 FIFO 로 가져온다. **백오프 중(`next_retry_at > now`)인 행은 건너뛴다**(dcy-015/REQ-019 head-of-line 보호, NFR-3). 2. row 의 현재 상태가 이미 `completed` 면 (e.g. 동시/재처리) `duplicate_completed` 로 마킹하고 어댑터를 호출하지 않는다. -3. `LedgerPort.record_trade(trade)` 호출. - - `LedgerWriteResult.NEW` → outcome `completed`, row status `completed`. - - `LedgerWriteResult.DUPLICATE_OK`(어댑터 server-side dedup) → outcome `duplicate_completed`, row status `completed`, ledger 중복 0. +3. `LedgerPort.record_trade(trade)` 호출 → `LedgerWriteOutcome{result, cash_balance_after?, asset_quantity_after?}`. + - `result=NEW` → outcome `completed`, row status `completed`. + - `result=DUPLICATE_OK`(어댑터 server-side dedup) → outcome `duplicate_completed`, row status `completed`, ledger 중복 0. + - **option D write-back(dcy-018/REQ-019/030)**: `new`·`duplicate` outcome 의 `cash_balance_after`/`asset_quantity_after`(None 아님)를 `SnapshotStore.update_cash_position(asset_id, …)` 로 반영(best-effort — 실패해도 ledger outcome 무차단, NFR-1). `*_after` 가 **절대 권위값**이라 duplicate 도 멱등. 응답 누락(구 hub)·409/422 는 write-back skip. - `LedgerConflictError`(portfolio-hub 409) → outcome `conflict_blocked`, row status `conflict_blocked` (자동 재시도 중단, 운영자 해소 대상). - `LedgerPermanentError`(portfolio-hub **404/400/422**, dcy-015/REQ-017, issue #13) → outcome `permanent_failure`, row status **`delivery_failed`**(터미널, 재시도 0). 재시도로 해소 불가한 클라이언트 오류(미등록 포트폴리오 등) → 즉시 중단 + WARN. - `LedgerRetryableError`(timeout/5xx/network/408/429) → **지수 백오프 + jitter**(`delay = min(base × 2^(attempts-1), cap)`, `next_retry_at` 설정) 후 다음 due tick 에 재시도. **`max_attempts` 초과 시** outcome `dead_lettered`, row status **`delivery_failed`**(dead-letter, 터미널) + WARN. 무한 1초 재시도 제거. diff --git a/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md b/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md index 5a8aaa4..2dc936e 100644 --- a/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md +++ b/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md @@ -188,3 +188,42 @@ #### Validation Plan - BAT-019: permanent 즉시 터미널, backoff 스킵, max_attempts dead-letter, PG 마이그레이션 round-trip 단위/인프라 게이트. + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-018) `LedgerWriter` 에 `SnapshotStore` 주입 + option D write-back: `new`·`duplicate` outcome 의 `*_after`(절대 권위값)를 `update_cash_position` 으로 반영(OQ-2 b, best-effort — 실패해도 ledger 멱등·청산 무차단). 추정 현금 오버레이용 `LedgerOutbox.pending_cash_delta()`(미정산 행 cash delta 합) 추가(InMemory + PG). BAT-023 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 체결 → portfolio-hub 권위 cash/quantity 를 스냅샷에 write-back(option D 활성화) + 공백 동안 추정 현금 산출 근거 제공. + +#### Acceptance Criteria Delta + +- Added: + - LedgerWriter 가 new+duplicate 에 best-effort write-back(*_after None 이면 skip). `pending_cash_delta()` 로 미정산 추정 합 제공. + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_outbox.py`(LedgerWriter write-back/`_writeback`/`trade_cash_delta`/`pending_cash_delta`), `trex_core/adapters/persistence/postgresql.py`(`pending_cash_delta` SQL), `trex_core/runtime/daemon.py`(SnapshotStore 주입) +- Tests: tests/application/test_ledger_option_d.py, tests/adapters/persistence/test_postgresql_ledger_outbox.py::test_pending_cash_delta_on_pg + +#### Delivery Trace Delta + +- Batches: BAT-023 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-023 에서 완료) +- Existing Implementation Invalidated: no (write-back/추정은 additive, 기존 outbox 멱등 불변) +- Status Recommendation: Implemented (change-req → BAT-023 재검증) +- Follow-up Action: confirm-req-implemented(BAT-023). + +#### Validation Plan + +- BAT-023: write-back new/duplicate/skip/best-effort + pending_cash_delta 단위 + PG. diff --git a/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md b/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md index dc125de..338721d 100644 --- a/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md +++ b/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md @@ -228,3 +228,42 @@ #### Validation Plan - BAT-019: 404/400/422→LedgerPermanentError, 429/5xx→retryable, 409→conflict 단위. + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-018) `record_trade` 반환을 `LedgerWriteResult` → **`LedgerWriteOutcome{result, cash_balance_after?, asset_quantity_after?}`** 로 확장. portfolio-hub 응답 root flat `cash_balance_after`/`asset_quantity_after`(option D, portfolio-hub#8)를 `new`(201)·`duplicate`(200)에서 파싱(누락 시 None). 409/422 는 기존대로 예외. BAT-023 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- ledger write 응답의 권위 cash/quantity 를 운반해 SnapshotStore write-back(REQ-019/030)을 가능하게 한다(매수/매도 직후 현금 stale 제거). + +#### Acceptance Criteria Delta + +- Added: + - `record_trade` → `LedgerWriteOutcome` 반환(result + `*_after`). 어댑터가 응답 root 의 `cash_balance_after`/`asset_quantity_after` 파싱(new+duplicate, 누락 시 None). + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_port.py`(LedgerWriteOutcome), `trex_core/adapters/ledger/portfolio_hub.py`(파싱), `trex_core/adapters/ledger/in_memory.py`(outcome 반환 + cash/qty 주입) +- Tests: tests/adapters/ledger/test_portfolio_hub_ledger.py(*_after new/duplicate/누락) + +#### Delivery Trace Delta + +- Batches: BAT-023 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-023 에서 완료) +- Existing Implementation Invalidated: yes (record_trade 반환 타입) +- Status Recommendation: Implemented (change-req → BAT-023 재검증) +- Follow-up Action: confirm-req-implemented(BAT-023). + +#### Validation Plan + +- BAT-023: 응답 *_after 파싱(new/duplicate/누락) 단위. diff --git a/docs/srs/Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md b/docs/srs/Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md index 0ff67a0..b0f53fd 100644 --- a/docs/srs/Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md +++ b/docs/srs/Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md @@ -1,6 +1,6 @@ # req-030: 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 -- Status: Approved +- Status: Implemented - Type: Integration - Priority: High - Owner: project maintainers @@ -133,3 +133,41 @@ #### Notes - BAT-008 verification.md `Human Approval Memo` 에 deferred residual risk 로 명시되어 있고, batch 전체는 release-candidate 로 promote 됨. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-018) **Status Approved → Implemented**. 연기됐던 option D(AC-2/AC-3: ledger write 응답 `*_after` 로 현금/포지션 갱신)를 portfolio-hub#8 배포·동작 확인 후 실제 구현. `LedgerWriter` 가 `*_after` 를 `SnapshotStore.update_cash_position` 으로 write-back(REQ-019), 어댑터가 `*_after` 파싱(REQ-017). REQ-030 Change Log Follow-up(CHG-20260606-03 "portfolio-hub#8 배포 후 minor-change 로 option D 구현") 예고대로 닫음. + +#### Intent + +- option D 현금/포지션 갱신을 실제 활성화해 AC-2/AC-3 충족 → Approved→Implemented 전환. + +#### Acceptance Criteria Delta + +- (변경 없음 — AC-2/AC-3 의 deferred 해소; ledger write 응답 `*_after` 소비 경로 구현) + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_outbox.py`(write-back), `trex_core/adapters/ledger/portfolio_hub.py`(*_after 파싱), `trex_core/runtime/daemon.py`(SnapshotStore 배선) +- Tests: tests/application/test_ledger_option_d.py + +#### Delivery Trace Delta + +- Batches: BAT-023 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-023 에서 AC-2/AC-3 통합 검증) +- Existing Implementation Invalidated: no (AC-1 baseline 불변, deferred AC 구현) +- Status Recommendation: **Implemented** (option D 활성화 완료) +- Follow-up Action: confirm-req-implemented(BAT-023) 로 Approved→Implemented 동기화. + +#### Validation Plan + +- BAT-023: ledger write `*_after` → SnapshotStore 갱신 단위 + 실 데몬 e2e(option D). diff --git a/docs/srs/Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md b/docs/srs/Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md index 15fb9c5..f468a8b 100644 --- a/docs/srs/Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md +++ b/docs/srs/Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md @@ -173,3 +173,43 @@ #### Validation Plan - 문서 전용 변경. 기존 AC-1~5 테스트 그대로 유효하며 재실행 불필요. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-018) 수량/현금 source 정합: 슬롯 오버레이(REQ-029)는 **유지**(lifecycle 중 슬롯 = 코어 권위 즉시값, 불변식 A). 스냅샷 응답에 미정산 ledger 가 있는 자산은 `cash_balance += pending delta` + `cash_estimated:true` 라벨로 **추정 현금 오버레이**(OQ-4 (i))를 노출하고, option D write-back 도착 시 권위값 수렴. BAT-023 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- "수량 live·현금 stale" 비대칭 제거 — 수량은 슬롯 오버레이(정확), 현금은 추정 오버레이(라벨)로 즉시성 확보 후 권위값 수렴. + +#### Acceptance Criteria Delta + +- Added: + - 스냅샷 자산에 추정 현금 오버레이(`cash_estimated` 플래그) — 미정산 outbox delta 기반. 슬롯 오버레이는 그대로 유지(변경 없음). + +#### Impacted Area Delta + +- Modules: `trex_core/application/messaging_handlers.py`(`_apply_cash_estimate`) +- Docs: `docs/interface-contract.md`(envelope `cash_estimated`) +- Tests: tests/application/test_ledger_option_d.py(estimate overlay) + +#### Delivery Trace Delta + +- Batches: BAT-023 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-023 에서 완료) +- Existing Implementation Invalidated: no (오버레이 유지 + 추정 additive) +- Status Recommendation: Implemented (change-req → BAT-023 재검증) +- Follow-up Action: confirm-req-implemented(BAT-023). + +#### Validation Plan + +- BAT-023: 미정산 자산 cash_estimated + 추정 cash 단위, write-back 후 수렴. diff --git a/docs/srs/index.md b/docs/srs/index.md index 5db5e18..27f50c4 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -110,7 +110,7 @@ | REQ-027 | portfolio-hub read 포트 + 어댑터 + stub | Integration | Implemented | High | project maintainers | [req-027](./Integration/req-027_portfolio-hub-read-port-and-adapter.md) | | REQ-028 | 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호 | Interface | Implemented | High | project maintainers | [req-028](./Interface/req-028_snapshot-read-message-contract-and-envelope.md) | | REQ-029 | 자산 선별(slot overlay) + view 모드 + strategy_key 필터 | Interface | Implemented | High | project maintainers | [req-029](./Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md) | -| REQ-030 | 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 | Integration | Approved | High | project maintainers | [req-030](./Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md) | +| REQ-030 | 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 | Integration | Implemented | High | project maintainers | [req-030](./Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md) | | REQ-031 | 포지션 정합 검증 + warning 마스킹 | Exception | Implemented | High | project maintainers | [req-031](./Exception/req-031_position-reconciliation-and-warning.md) | | REQ-032 | 런타임 pause/resume 제어 | Interface | Implemented | Medium | project maintainers | [req-032](./Interface/req-032_runtime-pause-resume-control.md) | | REQ-033 | 스냅샷 read 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-033](./Documentation/req-033_snapshot-read-baseline-alignment.md) | @@ -139,8 +139,8 @@ ## Recent Change Log Summary +- 2026-06-08 :: REQ-017/019/029 change-req + REQ-030 Approved→Implemented (dcy-018, BAT-023): ledger write 응답의 권위 cash/quantity 를 portfolio 스냅샷에 write-back(option D 활성화) — 매수/매도 직후 "수량 live·현금 stale" 비대칭 제거. `record_trade`→`LedgerWriteOutcome{result, *_after}`+어댑터 `cash_balance_after`/`asset_quantity_after` 파싱(new+duplicate)(REQ-017), `LedgerWriter` SnapshotStore write-back best-effort+`pending_cash_delta`(REQ-019), 추정 현금 오버레이(`cash_estimated` 라벨)+슬롯 오버레이 유지(REQ-029, OQ-1 B/OQ-4 i), option D 활성화로 REQ-030 Approved→Implemented. 전부 change-req(신규 REQ 없음, OQ-3 a). portfolio-hub#8(`*_after`) 동작 중. 단위 350 passed, 인프라 게이트 385 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-028 + REQ-032 change-req→Implemented (CHG-20260608-01/02 / -03/04, dcy-016 후속, BAT-022): `trex-core slots` 가 universe `assets`(portfolio)를 슬롯 상태로 오인 표시하던 갭 정정(10개 슬롯인데 universe 1개·status 없음 — BAT-007 HTTP 슬롯 read 제거 후 슬롯 상태 read 표면 부재가 근인). snapshot envelope 에 read-only per-slot `slots[]`(canonical `slot_to_dict`: slot_id/trading_date/status/symbol/qty/avg_price/entry_price/.../portfolio_id) additive 추가(REQ-028 AC-4, strategy `assets[]` 불변), `trex-core slots` 가 `slots[]`(실제 슬롯 상태) 표시·`--status` 슬롯 status 필터·`universe` 는 `assets[]` 유지(REQ-032). BAT-007 stub/BAT-008 envelope 슬롯 스키마 단일화. 신규 코드 데몬 + 실 인프라 운영 재현 PASS(slots=10·status·DB 일치·universe 분리·비침습). 단위 338 passed, ruff clean. pytest 인프라 게이트는 Human Approval Memo 수용. 둘 다 **Implemented**. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-034/021/037/038/039/032/042 change-req (dcy-017, BAT-021): 전략 이탈로 슬롯이 워치독(안전망) 소유로 넘어간 상태를 가시화(issue #12). snapshot RPC 응답에 holding 슬롯 워치독 상태(expired/last_seen) read-only 동봉(best-effort) + `WatchdogStore.last_seen`(REQ-034/021), `trex-core slots` WATCHDOG-OWNED 표시(REQ-032), safety-sell 워치독 소유 진입(WARN)/이탈(INFO)·발동(forced_close·stop_loss=WARN, take_profit·market_close=INFO) 로그+in-process 카운터(REQ-037/038/039), baseline 2문서 정합(REQ-042). best-effort(청산 무차단), Redis 미채택. 전부 change-req(신규 REQ 없음, OQ-3). 단위 335 passed, 인프라 게이트 369 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-032/022/021/034/026 change-req (dcy-016, BAT-020): 운영자 CLI 확장 — `universe`/`slots`(snapshot RPC)·`enter`(buy intent RPC)·`exit`(sell intent RPC) 추가(전부 데몬 RPC 통일, 단일 writer 보존, DB 직접 쓰기 없음)(REQ-032), CLI request-reply RPC 클라이언트 재사용(REQ-022), snapshot 요청 워치독 억제 플래그(`touch_watchdog`/`actor="operator"`)로 운영자 조회가 전략 생존 신호로 오인되지 않게(REQ-021/034, R-4), enter/exit 안전장치(확인 프롬프트/`--yes`/live 이중 ack/`--dry-run`)+`--json`+인스턴스 라우팅, baseline 2문서 정합(REQ-026). pause/resume 는 direct-DB 유지. 전부 change-req(신규 REQ 없음, OQ-3). 단위 327 passed, 인프라 게이트 361 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-017/019/018/020 change-req (dcy-015, BAT-019): portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도되던 버그(issue #13) 정정. 404/400/422→`LedgerPermanentError`(REQ-017), outbox 영속→터미널 `delivery_failed` 즉시 + retryable 지수 백오프+jitter+`next_retry_at`+max_attempts→dead-letter + `list_pending` 백오프 스킵 + 마이그레이션 007(status enum/컬럼) + 백오프 env(REQ-019), 터미널 WARN+카운터 가시화(REQ-018), baseline 3문서 정합(REQ-020). 전부 change-req(신규 REQ 없음, OQ-3). 단위 311 passed, 인프라 게이트 345 passed, ruff clean. (change-req/confirm-req-implemented) -- 2026-06-08 :: REQ-005/048/009 change-req (dcy-014, BAT-018): 당일 슬롯 생성 기준을 시스템 UTC 날짜에서 설정 타임존(기본 KST) 기준 `trading_date` 로 분리(timestamp 는 UTC 유지) + 데몬 1회 preallocation 을 `DailyPreallocationScheduler`(TZ 일자당 1회 멱등 fire, 재시작 없이 다음날 자동 생성)로 대체(REQ-005), TOML `trading_timezone`/`daily_preallocation_time` 키(REQ-048), runtime-flows/data-model 정합(REQ-009). 전부 change-req(신규 REQ 없음, OQ-3; 스케줄러는 REQ-030 패턴 준용). 단위 302 passed, 인프라 게이트 334 passed, ruff clean. (change-req/confirm-req-implemented) diff --git a/tests/adapters/ledger/test_portfolio_hub_ledger.py b/tests/adapters/ledger/test_portfolio_hub_ledger.py index be77c31..0ce32bb 100644 --- a/tests/adapters/ledger/test_portfolio_hub_ledger.py +++ b/tests/adapters/ledger/test_portfolio_hub_ledger.py @@ -13,6 +13,7 @@ from trex_core.application.ledger_port import ( LedgerPermanentError, LedgerRetryableError, LedgerTrade, + LedgerWriteResult, ) @@ -90,6 +91,39 @@ def test_client_errors_are_permanent(status): adapter.record_trade(_trade()) +def test_outcome_parses_cash_quantity_after_on_new(): + """dcy-018 / REQ-017: option D authoritative cash/quantity from 201 response.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"cash_balance_after": 300000.0, + "asset_quantity_after": 10}) + + out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade()) + assert out.result is LedgerWriteResult.NEW + assert out.cash_balance_after == 300000.0 and out.asset_quantity_after == 10.0 + + +def test_outcome_parses_after_on_duplicate(): + """duplicate(200) also carries current authoritative values (OQ-2 b).""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"duplicate": True, + "cash_balance_after": 50000.0, + "asset_quantity_after": 3}) + + out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade()) + assert out.result is LedgerWriteResult.DUPLICATE_OK + assert out.cash_balance_after == 50000.0 and out.asset_quantity_after == 3.0 + + +def test_outcome_after_none_when_response_omits(): + """Older hub without *_after → None → write-back skipped downstream.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"ok": True}) + + out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade()) + assert out.result is LedgerWriteResult.NEW + assert out.cash_balance_after is None and out.asset_quantity_after is None + + def test_429_stays_retryable(): """dcy-015 / REQ-017: rate-limit is transient → retryable (not permanent).""" def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/adapters/persistence/test_postgresql_ledger_outbox.py b/tests/adapters/persistence/test_postgresql_ledger_outbox.py index 5e7a50f..4836d08 100644 --- a/tests/adapters/persistence/test_postgresql_ledger_outbox.py +++ b/tests/adapters/persistence/test_postgresql_ledger_outbox.py @@ -89,6 +89,16 @@ def test_writer_marks_delivery_failed_permanent_on_pg(pg_clean): assert outbox.list_pending() == [] +def test_pending_cash_delta_on_pg(pg_clean): + """dcy-018 / REQ-029: estimated cash delta of pending rows summed by asset (SQL).""" + outbox = PostgreSQLLedgerOutbox(pg_clean) + outbox.enqueue(LedgerTrade(asset_id="PF-1", order_id="O-1", execution_id="cd-buy", + side="buy", qty=10, price=1000.0, fee=1.0, tax=0.0, + executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc))) + deltas = outbox.pending_cash_delta() + assert deltas["PF-1"] == -(10 * 1000.0 + 1.0) # buy => cash down by notional+fee + + def test_backoff_row_skipped_by_pending_scan_on_pg(pg_clean): """dcy-015 / REQ-019: a future next_retry_at excludes the row from list_pending.""" outbox = PostgreSQLLedgerOutbox(pg_clean) diff --git a/tests/application/test_ledger_option_d.py b/tests/application/test_ledger_option_d.py new file mode 100644 index 0000000..8b637e2 --- /dev/null +++ b/tests/application/test_ledger_option_d.py @@ -0,0 +1,121 @@ +"""Option D cash/position write-back + estimate overlay tests (dcy-018, issue: cash stale). + +Covers: LedgerWriter write-back to SnapshotStore on new/duplicate, skip when +*_after missing, best-effort isolation, and the snapshot handler's estimated-cash +overlay from pending outbox deltas (OQ-4). +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from trex_core.adapters.ledger.in_memory import InMemoryLedger +from trex_core.application.ledger_outbox import ( + InMemoryLedgerOutbox, + LedgerWriter, + trade_cash_delta, +) +from trex_core.application.ledger_port import LedgerTrade +from trex_core.application.messaging_handlers import _apply_cash_estimate +from trex_core.application.portfolio import PortfolioItem, SnapshotStore + +UTC = timezone.utc + + +def _trade(execution_id="e-1", asset="PF-1", side="buy", qty=10, price=1000.0, fee=1.0): + return LedgerTrade(asset_id=asset, order_id="O-1", execution_id=execution_id, + side=side, qty=qty, price=price, fee=fee, + executed_at=datetime(2026, 6, 4, tzinfo=UTC)) + + +def _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0): + s = SnapshotStore() + s.swap([PortfolioItem(portfolio_id=pid, symbol="005930", + cash_balance=cash, asset_quantity=qty)]) + return s + + +# --- write-back (REQ-019/030) ---------------------------------------------- +def test_writeback_updates_snapshot_on_new(): + store = _store_with() + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger(cash_after=300_000.0, quantity_after=10.0) + writer = LedgerWriter(outbox, ledger, snapshot_store=store) + outbox.enqueue(_trade()) + writer.run_once() + a = store.get("PF-1").item + assert a.cash_balance == 300_000.0 and a.asset_quantity == 10.0 + + +def test_writeback_applies_on_duplicate_too(): + store = _store_with(cash=1_000_000.0) + outbox = InMemoryLedgerOutbox() + # execution already recorded once → second write is duplicate, *_after still authoritative + ledger = InMemoryLedger(cash_after=300_000.0, quantity_after=10.0) + ledger.record_trade(_trade(execution_id="dup")) # pre-record → next is duplicate + writer = LedgerWriter(outbox, ledger, snapshot_store=store) + outbox.enqueue(_trade(execution_id="dup")) + writer.run_once() + assert store.get("PF-1").item.cash_balance == 300_000.0 # duplicate still wrote back + + +def test_writeback_skipped_when_after_missing(): + store = _store_with(cash=1_000_000.0) + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger() # no cash_after/quantity_after → None + writer = LedgerWriter(outbox, ledger, snapshot_store=store) + outbox.enqueue(_trade()) + writer.run_once() + assert store.get("PF-1").item.cash_balance == 1_000_000.0 # unchanged + + +def test_writeback_best_effort_does_not_block_completion(): + class _BoomStore: + def update_cash_position(self, *a, **k): + raise RuntimeError("boom") + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger(cash_after=1.0, quantity_after=1.0) + writer = LedgerWriter(outbox, ledger, snapshot_store=_BoomStore()) + outbox.enqueue(_trade()) + stats = writer.run_once() + assert stats.completed == 1 # ledger still completed despite write-back error + + +def test_no_writeback_without_store(): + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger(cash_after=1.0, quantity_after=1.0) + writer = LedgerWriter(outbox, ledger) # no snapshot_store + outbox.enqueue(_trade()) + assert writer.run_once().completed == 1 # no crash + + +# --- estimate overlay (REQ-029 / OQ-4) ------------------------------------- +def test_pending_cash_delta_sums_by_asset(): + outbox = InMemoryLedgerOutbox() + outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0)) + deltas = outbox.pending_cash_delta() + assert deltas["PF-1"] == trade_cash_delta(_trade(side="buy", qty=10, price=1000.0, fee=1.0)) + assert deltas["PF-1"] == -(10 * 1000.0 + 1.0) # buy: cash down + + +def test_apply_cash_estimate_overlays_pending_delta(): + outbox = InMemoryLedgerOutbox() + outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0)) + payload = {"assets": [{"asset_id": "PF-1", "symbol": "005930", + "cash_balance": 1_000_000.0, "asset_quantity": 0.0}]} + _apply_cash_estimate(payload, outbox) + a = payload["assets"][0] + assert a["cash_estimated"] is True + assert a["cash_balance"] == 1_000_000.0 - (10 * 1000.0 + 1.0) # estimated cash + + +def test_estimate_clears_after_writeback(): + """Once the ledger write completes (no longer pending), the estimate is gone.""" + store = _store_with(cash=1_000_000.0) + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger(cash_after=989_999.0, quantity_after=10.0) + writer = LedgerWriter(outbox, ledger, snapshot_store=store) + outbox.enqueue(_trade(side="buy", qty=10, price=1000.0, fee=1.0)) + assert outbox.pending_cash_delta() # estimate present while pending + writer.run_once() # completes → write-back authoritative + assert outbox.pending_cash_delta() == {} # no longer pending → no estimate + assert store.get("PF-1").item.cash_balance == 989_999.0 diff --git a/tests/application/test_ledger_outbox_backoff.py b/tests/application/test_ledger_outbox_backoff.py index 6107f54..5c5cd5d 100644 --- a/tests/application/test_ledger_outbox_backoff.py +++ b/tests/application/test_ledger_outbox_backoff.py @@ -19,6 +19,7 @@ from trex_core.application.ledger_port import ( LedgerPermanentError, LedgerRetryableError, LedgerTrade, + LedgerWriteOutcome, LedgerWriteResult, ) @@ -40,7 +41,7 @@ class _ProgrammableLedger: self.behavior = behavior self.calls = 0 - def record_trade(self, trade) -> LedgerWriteResult: + def record_trade(self, trade) -> LedgerWriteOutcome: self.calls += 1 return self.behavior(trade) @@ -142,7 +143,7 @@ def test_backoff_row_does_not_block_new_rows(): def behavior(trade): if trade.asset_id == "PF-bad": raise LedgerRetryableError("503") - return LedgerWriteResult.NEW + return LedgerWriteOutcome(LedgerWriteResult.NEW) outbox, ledger, writer = _wire( behavior, clock=lambda: now["t"], retry_base_sec=100.0, max_attempts=99, diff --git a/trex_core/adapters/ledger/in_memory.py b/trex_core/adapters/ledger/in_memory.py index 24a8332..d75fd32 100644 --- a/trex_core/adapters/ledger/in_memory.py +++ b/trex_core/adapters/ledger/in_memory.py @@ -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]: diff --git a/trex_core/adapters/ledger/portfolio_hub.py b/trex_core/adapters/ledger/portfolio_hub.py index 4fbeaa7..3598ef3 100644 --- a/trex_core/adapters/ledger/portfolio_hub.py +++ b/trex_core/adapters/ledger/portfolio_hub.py @@ -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}: " diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index bb21922..2e9fad2 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -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: diff --git a/trex_core/application/ledger_outbox.py b/trex_core/application/ledger_outbox.py index eb47add..aea94e7 100644 --- a/trex_core/application/ledger_outbox.py +++ b/trex_core/application/ledger_outbox.py @@ -69,6 +69,19 @@ _DELIVERY_FAILED_OUTCOMES = frozenset( ) +def trade_cash_delta(trade: LedgerTrade) -> float: + """Estimated cash impact of a trade (dcy-018 / REQ-029 OQ-4 estimate). + + buy → -(qty*price + fee + tax); sell → +(qty*price - fee - tax). The fee is + the core's approximation, so this is an estimate that converges to the + portfolio-hub authoritative value on option-D write-back.""" + notional = float(trade.qty) * float(trade.price) + fees = float(trade.fee) + float(trade.tax) + if trade.side == "sell": + return notional - fees + return -(notional + fees) + + @dataclass(frozen=True) class LedgerWorkItem: """One outbox row. @@ -102,6 +115,14 @@ class LedgerOutbox(Protocol): """Return pending work items in FIFO order, up to `limit`.""" ... + def pending_cash_delta(self) -> dict[str, float]: + """Sum the estimated cash delta of ALL not-yet-settled (pending) rows, + grouped by asset_id (dcy-018 / REQ-029, OQ-4 estimate overlay). + + buy → -(qty*price + fee + tax); sell → +(qty*price - fee - tax). + Used to show an estimated cash before option-D write-back lands.""" + ... + def get(self, correlation_id: str) -> Optional[LedgerWorkItem]: ... @@ -142,6 +163,7 @@ class LedgerWriter: outbox: LedgerOutbox, ledger: LedgerPort, *, + snapshot_store=None, retry_base_sec: float = _DEFAULT_RETRY_BASE_SEC, retry_cap_sec: float = _DEFAULT_RETRY_CAP_SEC, max_attempts: int = _DEFAULT_MAX_ATTEMPTS, @@ -150,6 +172,9 @@ class LedgerWriter: ) -> None: self._outbox = outbox self._ledger = ledger + # dcy-018 / REQ-019/030 (option D): SnapshotStore for cash/quantity + # write-back. None => write-back disabled (e.g. slice-1 / tests). + self._snapshot_store = snapshot_store # dcy-015 / REQ-019: exponential backoff + jitter + max-attempts cap. self._retry_base = retry_base_sec self._retry_cap = retry_cap_sec @@ -189,7 +214,7 @@ class LedgerWriter: self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED) return OutboxOutcome.DUPLICATE_COMPLETED try: - result = self._ledger.record_trade(item.trade) + outcome = self._ledger.record_trade(item.trade) except LedgerConflictError as exc: self._outbox.mark_outcome( item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc) @@ -227,15 +252,39 @@ class LedgerWriter: error=str(exc), next_retry_at=next_retry_at, ) return OutboxOutcome.RETRYABLE_FAILURE + # dcy-018 / REQ-019/030 (option D): write back portfolio-hub authoritative + # cash/quantity to the snapshot store on new+duplicate (OQ-2 b). *_after + # is absolute, so applying it on duplicate is idempotent. best-effort — + # never let a write-back failure block the ledger outcome (NFR-1). + self._writeback(item.trade, outcome) # Adapter-level dedup (server says "already recorded"): outcome is # duplicate_completed; the ledger has exactly one entry for this # execution_id either way (REQ-019 AC-1, AC-4). - if result is LedgerWriteResult.DUPLICATE_OK: + if outcome.result is LedgerWriteResult.DUPLICATE_OK: self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED) return OutboxOutcome.DUPLICATE_COMPLETED self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED) return OutboxOutcome.COMPLETED + def _writeback(self, trade: LedgerTrade, outcome) -> None: + """Option D cash/quantity write-back (dcy-018). best-effort; skips when + no SnapshotStore is wired or the response omits authoritative values.""" + if self._snapshot_store is None: + return + if outcome.cash_balance_after is None or outcome.asset_quantity_after is None: + return # older hub / missing fields → leave snapshot as-is + try: + self._snapshot_store.update_cash_position( + trade.asset_id, + outcome.cash_balance_after, + outcome.asset_quantity_after, + ) + except Exception: # noqa: BLE001 - write-back must not block the ledger path + _log.warning( + "snapshot cash/position write-back failed: asset_id=%s", + trade.asset_id, exc_info=True, + ) + # --- in-memory outbox (tests + slice-1 runtime) ------------------------------ @@ -288,6 +337,15 @@ class InMemoryLedgerOutbox: break return out + def pending_cash_delta(self) -> dict[str, float]: + with self._lock: + out: dict[str, float] = {} + for row in self._rows.values(): + if row.status is WorkItemStatus.PENDING: + aid = row.trade.asset_id + out[aid] = out.get(aid, 0.0) + trade_cash_delta(row.trade) + return out + def get(self, correlation_id: str) -> Optional[LedgerWorkItem]: with self._lock: return self._rows.get(correlation_id) diff --git a/trex_core/application/ledger_port.py b/trex_core/application/ledger_port.py index bb8c992..56dcf8e 100644 --- a/trex_core/application/ledger_port.py +++ b/trex_core/application/ledger_port.py @@ -29,6 +29,21 @@ class LedgerWriteResult(str, Enum): DUPLICATE_OK = "duplicate_ok" +@dataclass(frozen=True) +class LedgerWriteOutcome: + """Result of `record_trade` + portfolio-hub's authoritative cash/position + after the write (dcy-018 / REQ-017, option D). + + portfolio-hub#8 returns `cash_balance_after`/`asset_quantity_after` (root + flat, absolute authoritative values) on `new`(201) and `duplicate`(200); + they are None when the response omits them (older hub) so write-back is + skipped. 409/422 raise instead of returning an outcome.""" + + result: LedgerWriteResult + cash_balance_after: Optional[float] = None + asset_quantity_after: Optional[float] = None + + @dataclass(frozen=True) class LedgerTrade: """One ledger trade record (dcy-004 FR-1 contract).""" @@ -82,16 +97,18 @@ class LedgerPort(Protocol): stub mimics the same contract for deterministic tests. """ - def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult: - """Append a trade to the ledger. + def record_trade(self, trade: LedgerTrade) -> LedgerWriteOutcome: + """Append a trade to the ledger (dcy-018 / REQ-017). Returns: - LedgerWriteResult.NEW on a fresh entry, - LedgerWriteResult.DUPLICATE_OK when the adapter recognized the - execution_id as already recorded. + LedgerWriteOutcome — `.result` is NEW (fresh entry) or DUPLICATE_OK + (execution_id already recorded), plus `.cash_balance_after` / + `.asset_quantity_after` (portfolio-hub authoritative values, or None + when the response omits them, option D). Raises: LedgerConflictError: ledger reports an unrecoverable conflict (409). + LedgerPermanentError: non-retryable client error (404/400/422). LedgerRetryableError: transient failure; outbox should retry. """ ... diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index dd661fb..77684e7 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -171,6 +171,7 @@ def make_snapshot_rpc_handler( watchdog_store: Optional[WatchdogStore] = None, watchdog_timeout_sec: int = 120, killswitch_store=None, + ledger_outbox=None, ): """Build the RPC handler for `trex.snapshot.req` (REQ-028). @@ -229,6 +230,7 @@ def make_snapshot_rpc_handler( ) payload = envelope.to_dict() _attach_watchdog(payload, slot_repo, day, watchdog_store, watchdog_timeout_sec) + _apply_cash_estimate(payload, ledger_outbox) return RpcResponse(payload=payload) # --- BAT-007 legacy stub-shape path --- @@ -259,6 +261,27 @@ def make_snapshot_rpc_handler( return handler +def _apply_cash_estimate(payload, ledger_outbox) -> None: + """Overlay an estimated cash for assets with unsettled (pending) ledger + writes (dcy-018 / REQ-029, OQ-4). cash_balance += pending cash delta and the + asset is flagged `cash_estimated=true` until option-D write-back lands. + Best-effort: any failure leaves the response unchanged (NFR-1).""" + if ledger_outbox is None: + return + try: + deltas = ledger_outbox.pending_cash_delta() + if not deltas: + return + for a in payload.get("assets", []): + d = deltas.get(a.get("asset_id")) + if d: + base = a.get("cash_balance") or 0.0 + a["cash_balance"] = base + d + a["cash_estimated"] = True + except Exception: # noqa: BLE001 - estimate must never break the response + return + + def _attach_watchdog(payload, slot_repo, day, watchdog_store, timeout_sec) -> None: """Attach holding-slot watchdog state to a snapshot response (dcy-017/REQ-034). diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 7ea969f..8e3a878 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -147,15 +147,16 @@ class Daemon: ) self.broker = _build_broker(config) self.outbox = PostgreSQLLedgerOutbox(config.dsn) + # BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler. + # (Created before the ledger writer so option-D write-back can target it.) + self.snapshot_store = SnapshotStore() self.ledger_writer = LedgerWriter( self.outbox, _build_ledger(config), + snapshot_store=self.snapshot_store, # dcy-018 / REQ-030: option D write-back retry_base_sec=config.ledger_retry_base_sec, retry_cap_sec=config.ledger_retry_cap_sec, max_attempts=config.ledger_max_attempts, ) - - # BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler. - self.snapshot_store = SnapshotStore() # BAT-010/011: per-slot watchdog (snapshot RPC writer, safety-sell reader). self.watchdog = WatchdogStore() self.pause_store = PostgreSQLPauseStore(config.dsn) @@ -273,6 +274,7 @@ class Daemon: watchdog_store=self.watchdog, watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec, killswitch_store=self.killswitch_store, + ledger_outbox=self.outbox, # dcy-018 / REQ-029: estimated-cash overlay ) transport.consume_rpc_forever( topology.snapshot_queue(self.config.instance_key), handler, self.stop)