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:
@@ -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) 정합 — 기존 테스트 더블/소비처 전환.
|
||||
+41
@@ -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.
|
||||
@@ -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)으로 짧음.
|
||||
@@ -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` 는 인프라 게이트로 검증.
|
||||
@@ -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 은 운영 배포 검증 비차단.
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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초 재시도 제거.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/누락) 단위.
|
||||
|
||||
+39
-1
@@ -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).
|
||||
|
||||
@@ -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 후 수렴.
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user