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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 동기화는 후속 범위다.
|
||||
|
||||
@@ -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 해소.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user