From 9e64a24441eea97c7593e75ee3ea167f435d3b89 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 6 Jun 2026 11:27:30 +0900 Subject: [PATCH] fix: align portfolio-hub ledger write adapter to live API contract REQ-017 CHG-20260606-01. Live portfolio-hub returned 404 for the trex-orbit-assumed POST /v1/assets/{asset_id}/ledger/trades. Correct to POST /v1/portfolios/{portfolio_id}/ledger/trades with body keys quantity/fee_amount/tax_amount (was qty/fee/tax), success 201. Reverified with live e2e (201 Created, outbox COMPLETED) + durability drain of the prior 404'd item. Resolves the REL-001 ledger-write release blocker. Co-Authored-By: Claude Opus 4.7 --- .../design.md | 2 +- .../verification.md | 12 ++++- ...7_ledger-port-and-portfolio-hub-adapter.md | 48 +++++++++++++++++-- docs/srs/index.md | 2 +- .../ledger/test_portfolio_hub_ledger.py | 15 ++++-- trex_core/adapters/ledger/portfolio_hub.py | 21 ++++---- 6 files changed, 81 insertions(+), 19 deletions(-) diff --git a/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/design.md b/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/design.md index 6e9085a..2f8be7e 100644 --- a/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/design.md +++ b/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/design.md @@ -14,7 +14,7 @@ - adapters/broker: paper/KIS `FillStatus.execution_id` 합성, fee 계산 운반. - runtime: portfolio-hub config, ledger retry loop lifecycle. - persistence: ledger outbox work item store/schema. -- Interfaces: `LedgerPort.record_trade(...)`, `FillStatus.execution_id/fee`, portfolio-hub `POST /v1/assets/{asset_id}/ledger/trades`. +- Interfaces: `LedgerPort.record_trade(...)`, `FillStatus.execution_id/fee`, portfolio-hub `POST /v1/portfolios/{portfolio_id}/ledger/trades` (body `quantity`/`fee_amount`/`tax_amount`, 성공 201). (정정: REQ-017 CHG-20260606-01 — 구 `POST /v1/assets/{asset_id}/...`, body `qty`/`fee`/`tax` 폐기) ## Key Decisions 1. `asset_id == portfolio_id`: 슬롯의 `portfolio_id` 를 ledger path param 으로 직접 사용한다. 매핑 조회나 asset master 동기화는 후속 범위다. diff --git a/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/verification.md b/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/verification.md index 29f75c4..dc75e94 100644 --- a/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/verification.md +++ b/docs/batches/bat-006_20260604_portfolio-hub-ledger-integration/verification.md @@ -6,7 +6,7 @@ ## Acceptance Mapping - REQ-017: - AC-1: in-memory stub 원장 `record_trade(...)` 1회 호출 시 거래 1건 적재와 필드 보존 evidence. - - AC-2: portfolio-hub adapter 가 `POST /v1/assets/{asset_id}/ledger/trades` 에 `execution_id` 포함 payload 를 전송하는 contract evidence. + - AC-2: portfolio-hub adapter 가 `POST /v1/portfolios/{portfolio_id}/ledger/trades` 에 `execution_id` 포함 payload(body `quantity`/`fee_amount`/`tax_amount`)를 전송하는 contract evidence. (정정: REQ-017 CHG-20260606-01 — 아래 Re-verification Addendum) - AC-3: portfolio-hub 409 응답이 `LedgerConflictError` 또는 `conflict_blocked` 경로로 표면화되는 evidence. - AC-4: `trex_core/domain` portfolio-hub/HTTP 의존 0 import sweep evidence. - REQ-018: @@ -32,7 +32,7 @@ - 명령: `.venv/bin/python -m pytest -q` - 결과: `109 passed, 12 skipped, 1 warning in 0.98s` (PG 4건 + KIS 1건 + 기존 PG 7건은 DSN/credentials 게이트로 skip). - REQ-017 AC-1: `tests/adapters/ledger/test_in_memory_ledger.py::test_record_trade_preserves_fields_and_is_idempotent_on_execution_id` — 단일 호출 후 `count()==1` + 필드 보존, 동일 execution_id 재호출 후에도 `count()==1`. -- REQ-017 AC-2: `tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_asset_id` — `httpx.MockTransport` 로 path `/v1/assets/PF-77/ledger/trades`, body 에 `execution_id`/`side` 포함, `Authorization: Bearer …` 헤더 캡처. +- REQ-017 AC-2: `tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_portfolio_id` — `httpx.MockTransport` 로 path `/v1/portfolios/PF-77/ledger/trades`, body 에 `execution_id`/`side`/`quantity`/`fee_amount`/`tax_amount` 포함(+`qty`/`fee`/`tax` 부재), `Authorization: Bearer …` 헤더 캡처. (정정: REQ-017 CHG-20260606-01 — 아래 Re-verification Addendum) - REQ-017 AC-3: `tests/adapters/ledger/test_portfolio_hub_ledger.py::test_409_surfaces_as_ledger_conflict_error` + `tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry` — 409 → `LedgerConflictError`; outbox row `status=conflict_blocked`, 후속 writer tick 에서 어댑터 재호출 없음. - REQ-017 AC-4: `tests/domain/test_domain_import_sweep.py::test_domain_has_no_forbidden_imports` — `trex_core/domain` AST 워크가 `httpx`/`fastapi`/`uvicorn`/`psycopg`/`redis`/`trex_core.adapters`/`trex_core.runtime`/`trex_core.application` 0 회 import 확인. - REQ-018 AC-1: `tests/application/test_execution_ledger.py::test_buy_fill_enqueues_one_ledger_item_and_writer_records_it` — buy intent → execution.run_once → slot=holding, ledger_enqueued=1, writer.run_once → ledger.count()=1, `LedgerTrade(asset_id="PF-1", side="buy", qty=10, price=1000.0)`. @@ -69,3 +69,11 @@ - Notes: - portfolio-hub 실 계약/자격증명 evidence 는 stub + contract 수준이며, 실 통합 검증은 운영 단계에서 수집한다(dcy-004 DATA 위임 사항). - 부분체결 누적/정밀 세금 모델은 후속 Discovery 로 분리됨(이번 범위 밖). + +## Re-verification Addendum (2026-06-06, REQ-017 CHG-20260606-01) +- 트리거: RabbitMQ 매수 인텐트 → 체결 → ledger 기록 실 e2e 중 ledger write 가 live portfolio-hub 에서 404 발생. 원인은 어댑터가 구 orbit 가정 계약(`POST /v1/assets/{asset_id}/ledger/trades`, body `qty`/`fee`/`tax`)을 사용한 것. 실 계약은 openapi `LedgerTradeRequest` 기준 `POST /v1/portfolios/{portfolio_id}/ledger/trades` + `quantity`/`fee_amount`/`tax_amount`(성공 201). +- 정정 구현: `trex_core/adapters/ledger/portfolio_hub.py` (path + body 키), `tests/adapters/ledger/test_portfolio_hub_ledger.py` (실 계약 단언). +- 재검증 결과: + - 단위/통합: `.venv/bin/python -m pytest tests/adapters/ledger/ tests/application/test_execution_ledger.py -q` → `16 passed`. `.venv/bin/ruff check trex_core/adapters/ledger tests/adapters/ledger` → All checks passed. + - 실 e2e (live portfolio-hub 127.0.0.1:18000): 데몬 기동 → 매수 인텐트(005930, 10주, 300,000) 어드미션 → 슬롯 `idle→pending_buy→holding`(qty=10, avg_price=300000) → ledger writer `POST /v1/portfolios/{portfolio_id}/ledger/trades` **201 Created** → `ledger_outbox.status=completed`(last_outcome=completed). 직전 404 로 쌓였던 outbox 항목이 정정 후 재기동 시 무유실 드레인되어 REQ-019 durability 도 동시 실증(attempts 누적, 유실 0). +- 영향: REQ-017 Implemented 유지(정정 후 실 계약 일치). REQ-018/019/020 evidence 는 영향 없음(포트/실행 경로/outbox 시맨틱 불변). REL-001(confirmed) 의 ledger write release blocker 해소. 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 581dab2..d691902 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 @@ -10,13 +10,13 @@ ## Requirement - `LedgerPort.record_trade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` Protocol 을 application 레이어에 정의한다. -- `PortfolioHubLedger` 어댑터는 trex-orbit `post_ledger_trade` 패턴을 따라 `POST /v1/assets/{asset_id}/ledger/trades` 로 거래를 기록하고, `execution_id` 로 멱등하며, portfolio-hub 가 반환하는 409 충돌을 `LedgerConflictError` 로 표면화한다. +- `PortfolioHubLedger` 어댑터는 라이브 portfolio-hub 계약(`LedgerTradeRequest`)을 따라 `POST /v1/portfolios/{portfolio_id}/ledger/trades` 로 거래를 기록한다. 요청 body 는 `side`/`quantity`/`price`/`fee_amount`/`tax_amount`/`executed_at`/`execution_id`/`order_id` 키를 사용하며, `execution_id` 로 멱등하고, portfolio-hub 가 반환하는 409 충돌을 `LedgerConflictError` 로 표면화한다. (정정: CHG-20260606-01 — 구 orbit 가정 `POST /v1/assets/{asset_id}/...`, body `qty`/`fee`/`tax` 폐기) - 결정론 테스트용 stub/in-memory 원장 구현을 제공해 네트워크 없이 체결→기록 lifecycle 을 검증할 수 있게 한다. - 어댑터 자격증명/URL 은 .env(REQ-010 패턴, `TREX_PORTFOLIO_HUB_*`)로 주입한다. ## Acceptance Criteria 1. Given in-memory stub 원장, When `record_trade(...)` 를 1회 호출, Then 원장에 거래 1건이 적재되고 인자(asset_id/side/qty/price/fee/tax/executed_at)가 보존된다. -2. Given portfolio-hub 어댑터, When `record_trade(...)`, Then `POST /v1/assets/{asset_id}/ledger/trades` 가 `execution_id` 를 포함해 호출된다(요청 계약은 orbit `post_ledger_trade` 와 동일). +2. Given portfolio-hub 어댑터, When `record_trade(...)`, Then `POST /v1/portfolios/{portfolio_id}/ledger/trades` 가 `execution_id` 를 포함해 호출되고 요청 body 는 `side`/`quantity`/`price`/`fee_amount`/`tax_amount`/`executed_at`/`execution_id`/`order_id` 키를 사용한다(라이브 `LedgerTradeRequest` 계약, 성공 응답 201). (정정: CHG-20260606-01) 3. Given portfolio-hub 가 409(중복/충돌)를 반환, When 어댑터가 응답을 처리, Then `LedgerConflictError` 로 표면화된다(예외를 삼키지 않는다). 4. Given 정적 import 스윕, When `trex_core/domain` 을 점검, Then portfolio-hub/네트워크 의존이 0 이다(LedgerPort 는 application 계약, 어댑터는 adapters 레이어 격리). (NFR-1) @@ -27,7 +27,7 @@ ## Notes - 출처: dcy-004 FR-1, NFR-1. -- A-1 가정: portfolio-hub 는 orbit 과 동일 계약(`POST /v1/assets/{asset_id}/ledger/trades`, execution_id 멱등, 409 conflict)을 제공한다. 실제 계약/자격증명 검증은 dcy-004 confirm 시 REQ/delivery evidence 로 위임(비차단)됨 — 구현/검증 시 수집. +- A-1 가정(폐기, CHG-20260606-01): orbit 동일 계약(`POST /v1/assets/{asset_id}/ledger/trades`, body `qty`/`fee`/`tax`) 가정은 라이브 portfolio-hub 실측과 불일치(404)로 확인돼 폐기. 실 계약은 `POST /v1/portfolios/{portfolio_id}/ledger/trades` + `LedgerTradeRequest`(`quantity`/`fee_amount`/`tax_amount`), 성공 201. execution_id 멱등은 유지 가정(중복 응답 코드는 후속 확인). - 도메인 순수성(NFR-1)을 별도 REQ 로 승격하지 않고 본 REQ 의 AC-4(import 스윕)로 흡수한다. ## Change Log @@ -147,3 +147,45 @@ - AC-2: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_asset_id. - AC-3: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_409_surfaces_as_ledger_conflict_error + tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry. - AC-4: tests/domain/test_domain_import_sweep.py::test_domain_has_no_forbidden_imports. + +### CHG-20260606-01 +- Date: 2026-06-06 +- Author: Justin Ko + +#### Change Summary +- 실 portfolio-hub API 와의 ledger write 계약 불일치를 정정: 엔드포인트 `POST /v1/assets/{asset_id}/ledger/trades`(부재, 404) → **`POST /v1/portfolios/{portfolio_id}/ledger/trades`**, 요청 body 키 `qty`/`fee`/`tax` → **`quantity`/`fee_amount`/`tax_amount`**. `asset_id` 값은 슬롯 `portfolio_id`(REQ-018: asset_id == portfolio_id)라 `{portfolio_id}` path 세그먼트에 직접 매핑된다. 어댑터/테스트는 정정 구현·재검증 완료. + +#### Intent +- 가정(A-1, orbit `post_ledger_trade` 동일 계약)으로 작성됐던 ledger write 계약을 라이브 portfolio-hub 실측(openapi `LedgerTradeRequest`)에 맞춰 정정하고, 정정 구현의 재검증 evidence(신규 contract 테스트 + 실 e2e 201)를 연결한다. + +#### Acceptance Criteria Delta +- Added: none +- Updated: + - AC-2: portfolio-hub 어댑터 `record_trade(...)` 는 `POST /v1/portfolios/{portfolio_id}/ledger/trades` 를 호출하고, 요청 body 는 `side`/`quantity`/`price`/`fee_amount`/`tax_amount`/`executed_at`/`execution_id`/`order_id` 키를 사용한다(`execution_id` 포함, `execution_id` 멱등). 성공 응답은 201. +- Removed: none + +#### Impacted Area Delta +- Docs: + - 본 REQ Requirement/AC-2/Notes 정정 + - BAT-006 design.md/verification.md 계약 정정 반영(같은 변경 묶음) +- Modules: + - trex_core/adapters/ledger/portfolio_hub.py (경로 + body 키 정정) +- Tests: + - tests/adapters/ledger/test_portfolio_hub_ledger.py (실 계약으로 갱신: path /v1/portfolios, body 키) + +#### Delivery Trace Delta +- Batches: + - BAT-006 (release-candidate) — ledger write 계약 정정 반영, verification 재확인 대상 +- Releases: + - REL-001 (confirmed) — BAT-006 포함. ledger write 경로가 live portfolio-hub 대상으로 동작하지 않던 release blocker 를 해소. release 문서는 직접 덮어쓰지 않고 본 항목으로 영향·후속 action 기록(rollout/verification 시 ledger POST 201 확인 항목 유효). + +#### Revalidation Impact +- Reverification Needed: yes — 완료. 정정 어댑터에 대해 신규 contract 테스트 통과 + 실 portfolio-hub e2e(매수 체결 → `POST /v1/portfolios/{portfolio_id}/ledger/trades` 201 Created, outbox COMPLETED) 확인. 직전 404 로 쌓였던 outbox 항목이 정정 후 재기동 시 무유실 드레인되어 durability(REQ-019) 도 함께 실증. +- Existing Implementation Invalidated: no — 정정 후 현재 코드가 실 계약과 일치. 무효화된 것은 구 가정(A-1)에 기반한 엔드포인트/필드 기술뿐. +- Status Recommendation: Implemented 유지. +- Follow-up Action: 멱등/중복 응답 시그널(현 어댑터는 2xx body `duplicate` 플래그 + 409 처리 유지) 은 실 API 가 중복 execution_id 에 어떤 코드를 반환하는지 확인되면 후속 정합. + +#### Validation Plan +- AC-2: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_portfolio_id (path `/v1/portfolios/PF-77/ledger/trades` + body `quantity`/`fee_amount`/`tax_amount` 단언, `qty`/`fee`/`tax` 부재 단언). +- AC-1/AC-3/AC-4: 기존 테스트 회귀(16 passed) 유지. +- 실 e2e: 매수 intent → 체결 → ledger POST 201 + outbox completed. diff --git a/docs/srs/index.md b/docs/srs/index.md index f1f05c3..1a2b2e0 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -117,8 +117,8 @@ ## Recent Change Log Summary +- 2026-06-06 :: REQ-017 CHG-20260606-01 — 실 portfolio-hub ledger write 계약 정정(`POST /v1/portfolios/{portfolio_id}/ledger/trades`, body `quantity`/`fee_amount`/`tax_amount`, 성공 201). 구 orbit 가정(`/v1/assets/{asset_id}`, `qty`/`fee`/`tax`) 폐기. 정정 구현 재검증 완료(unit 16 + 실 e2e 201)·Implemented 유지. REL-001 ledger blocker 해소. (change-req) - 2026-06-06 :: REQ-027 CHG-04 — 실 portfolio-hub 계약 정정(whoami `/v1/user_info`, `{portfolios}` envelope, 클라이언트측 daemon 필터, 전략 필드 None/`{}` 이월). 정정 구현 재검증 완료·Implemented 유지. (change-req) - 2026-06-06 :: REQ-029 CHG-04 — orb-v1 소비 참고 사항 추가(strategy_key 없이 view=buyable, 전략 필드 부재 무해). 문서 전용·Implemented 유지. (change-req) - 2026-06-06 :: REQ-027~029, REQ-031~033 Approved → Implemented — BAT-008 release-candidate verification evidence 기반. REQ-030 은 AC-1 evidence 확보·AC-2/AC-3 portfolio-hub#8 배포 대기로 Approved 유지. (confirm-req-implemented) - 2026-06-06 :: REQ-027~033 Proposed → Approved — dcy-005 confirmed, 전체 승인 게이트 통과. REQ-030 옵션 D 구현은 portfolio-hub#8 배포 후 착수. (confirm-req) -- 2026-06-06 :: REQ-027~033 최초 생성(Proposed) — dcy-005 FR-1~FR-9 분해(NFR-1 → REQ-027 흡수, NFR-2 → REQ-030 흡수, NFR-3 → REQ-028 흡수). (draft-req) diff --git a/tests/adapters/ledger/test_portfolio_hub_ledger.py b/tests/adapters/ledger/test_portfolio_hub_ledger.py index 8e04a3f..c8543d4 100644 --- a/tests/adapters/ledger/test_portfolio_hub_ledger.py +++ b/tests/adapters/ledger/test_portfolio_hub_ledger.py @@ -1,6 +1,7 @@ """portfolio-hub HTTP ledger adapter tests (REQ-017 AC-2/3).""" from __future__ import annotations +import json from datetime import datetime, timezone import httpx @@ -26,7 +27,7 @@ def _client(handler): return httpx.Client(transport=httpx.MockTransport(handler)) -def test_post_contract_includes_execution_id_and_path_uses_asset_id(): +def test_post_contract_includes_execution_id_and_path_uses_portfolio_id(): captured = {} def handler(request: httpx.Request) -> httpx.Response: @@ -38,9 +39,15 @@ def test_post_contract_includes_execution_id_and_path_uses_asset_id(): adapter = PortfolioHubLedger("https://portfolio-hub.test", token="tok", client=_client(handler)) adapter.record_trade(_trade(asset="PF-77")) - assert captured["url"] == "https://portfolio-hub.test/v1/assets/PF-77/ledger/trades" - assert "execution_id" in captured["body"] - assert "\"side\":\"buy\"" in captured["body"] + assert captured["url"] == "https://portfolio-hub.test/v1/portfolios/PF-77/ledger/trades" + body = json.loads(captured["body"]) + # Live contract field names (REQ-017 CHG-04): quantity / fee_amount / tax_amount. + assert body["execution_id"] == "paper:PAPER-000001:10" + assert body["side"] == "buy" + assert body["quantity"] == 10 + assert body["fee_amount"] == 1.5 + assert body["tax_amount"] == 0.0 + assert "qty" not in body and "fee" not in body and "tax" not in body assert captured["auth"] == "Bearer tok" diff --git a/trex_core/adapters/ledger/portfolio_hub.py b/trex_core/adapters/ledger/portfolio_hub.py index 775d2e1..751c5dd 100644 --- a/trex_core/adapters/ledger/portfolio_hub.py +++ b/trex_core/adapters/ledger/portfolio_hub.py @@ -1,12 +1,17 @@ """portfolio-hub ledger HTTP adapter (REQ-017, dcy-004). -Mirrors trex-orbit `PortfolioHubClient.post_ledger_trade`: - POST /v1/assets/{asset_id}/ledger/trades - body keys: side, qty, price, fee, tax, executed_at, execution_id, order_id - - 2xx => recorded (or duplicate accepted idempotently) +Live portfolio-hub contract (verified against openapi, REQ-017 CHG-04): + POST /v1/portfolios/{portfolio_id}/ledger/trades + body keys: side, quantity, price, fee_amount, tax_amount, executed_at, + execution_id, order_id + - 2xx (201) => recorded (or duplicate accepted idempotently) - 409 => LedgerConflictError (operator resolution) - 4xx (other)/5xx => LedgerRetryableError (outbox retries) - httpx error => LedgerRetryableError + +`asset_id` on LedgerTrade carries the portfolio_id value (REQ-018: +asset_id == portfolio_id), so it maps directly onto the {portfolio_id} +path segment. """ from __future__ import annotations @@ -48,10 +53,10 @@ class PortfolioHubLedger: def _payload(trade: LedgerTrade) -> dict: return { "side": trade.side, - "qty": trade.qty, + "quantity": trade.qty, "price": trade.price, - "fee": trade.fee, - "tax": trade.tax, + "fee_amount": trade.fee, + "tax_amount": trade.tax, "executed_at": trade.executed_at.isoformat() if trade.executed_at else None, "execution_id": trade.execution_id, "order_id": trade.order_id, @@ -65,7 +70,7 @@ class PortfolioHubLedger: timeout=self._timeout) def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult: - url = f"{self._base_url}/v1/assets/{trade.asset_id}/ledger/trades" + url = f"{self._base_url}/v1/portfolios/{trade.asset_id}/ledger/trades" try: resp = self._post(url, json=self._payload(trade)) except httpx.HTTPError as exc: