--- id: "dcy-0008" status: confirmed --- # 기업행동 원가를 WAC replay에 반영 (#37) ## Problem 기업행동이 기록된 portfolio에서 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis`가 기업행동 후 수량과 기업행동 전 WAC를 섞어 반환한다. `019685` 사례에서 portfolio snapshot 수량은 `726`이지만 endpoint는 병합 전 단가 `1,086.2341459366392`를 사용하여 총원가를 `788,605.9899500001`로 계산한다. 기업행동 감사 기록은 병합 후 수량 `726`과 authoritative `total_cost_basis=1,577,211.97990000`을 이미 보유한다. 이 불완전 응답은 downstream의 안전 gate를 활성화하여 자동 reconcile을 중단시킨다. ## Evidence - `E-001` Source issue `https://github.com/fourblocks2020/portfolio-hub/issues/37`은 운영 사례, 확인된 원인, 수정 사양과 acceptance criteria를 기록한다. 확인 시점의 issue body SHA-256은 `79acbcd09e22c309a8438efde32ff12b9414815557e28ad36802a71c9200bdec`, `updated_at`은 `2026-09-19T02:20:12Z`다. - `E-002` 기준 source는 `portfolio-hub` `f79f3c79b5e9c7e13fb28260af71e7e56cf575f5`다. `app/repositories/postgres.py::get_all_trades_for_pnl()`은 기업행동 metadata를 `LedgerEntry.corporate_action`에 보존하며 `executed_at ASC, ledger_entry_id ASC` 순서로 반환한다. - `E-003` `app/api/v1_portfolios.py::get_holdings_cost_basis()`는 ledger entry를 domain replay row로 변환할 때 `side`, `quantity`, `price`, `fee_amount`, `executed_at`만 전달하므로 `corporate_action` metadata를 버린다. 이후 portfolio snapshot 수량과 `calculate_current_wac()`의 unit WAC를 곱하여 총원가를 다시 만든다. - `E-004` `app/domain/pnl.py::calculate_current_wac()`에는 `stock_consolidation` 분기가 이미 있으나 metadata가 없으면 running state에서 값을 추정하는 fallback이 있고, 반환형 `CurrentWacResult`는 authoritative total을 보존하지 않는다. - `E-005` `app/schemas/v1.py::HoldingsCostBasisResponse`는 `cost_basis_per_unit`과 `total_cost_basis`를 이미 nullable로 정의하므로 신규 wire field나 DB migration 없이 fail-closed 응답을 표현할 수 있다. - `E-006` `docs/interface-contract.md`는 stock consolidation의 `total_cost_basis`를 authoritative pre-action acquisition cost로 정의하고, persisted 8-decimal WAC의 반올림 residue가 정상임을 명시한다. - `E-007` active `AC-0037`은 joined repository read와 corporate-action metadata mapping 보존을 소유하지만, `req-0007`은 WAC·원가 회계 규칙 변경을 명시적으로 제외한다. Authoritative total을 holdings 응답까지 보존하는 동작은 별도의 active AC가 필요하다. ## Scope - `S-001` holdings endpoint가 `LedgerEntry.corporate_action`의 `quantity_before`, `quantity_after`, `total_cost_basis`와 식별·순서 metadata를 domain replay row에 전달하도록 한다. 이는 existing `AC-0037`을 `satisfy`한다. - `S-002` 유효한 `stock_consolidation`을 replay할 때 running quantity를 `quantity_after`로 바꾸고, authoritative `total_cost_basis`를 보존하며, `cost_basis_per_unit = total_cost_basis / quantity_after`로 계산한다. 이 endpoint 원가 계약은 지속적인 독립 검증을 위해 새 AC를 `create`한다. - `S-003` `quantity_after`가 없거나 0 이하이거나 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않는다. holdings 응답의 두 원가 필드를 `null`로 반환하고 기존 `data_completeness_warning` 필드에 기업행동 metadata 부족을 구분하는 안정적인 문자열을 반환한다. 신규 response field는 만들지 않는다. - `S-004` 정상 기업행동, metadata 부족, 기업행동 이후 BUY/SELL, BUY/SELL-only 회귀와 API metadata 전달을 domain·API·PostgreSQL integration 수준에서 검증한다. 기존 trex-core source를 변경하지 않고 consumer compatibility를 검증한다. - `S-005` interface contract에 holdings cost-basis의 기업행동 원가 출처와 계산식을 기록한다. ## Out of Scope - `OS-001` trex-core source·허용오차·slot 상태 변경, broker 주문, B warning 삭제 또는 production data 보정. - `OS-002` `0.1%`, `1,000원 상한`, 소수점 8자리 일치 같은 추가 tolerance 정책. - `OS-003` corporate-action write transaction, idempotency, recovery, cash, profit buffer, portfolio reference price 또는 DB schema 변경. - `OS-004` holdings 응답에 신규 reason field를 추가하거나 기존 BUY/SELL-only WAC 계약을 변경하는 일. ## Risks and Unknowns ### Risks - `R-001` 현재 WAC domain은 `float`를 사용한다. 저장된 8자리 `average_price_after`를 authoritative total 재구성에 사용하면 반올림 residue가 생기므로, total은 별도 replay state로 보존하고 endpoint가 그 값을 그대로 반환해야 한다. - `R-002` 기업행동 metadata 부족 상태에서 기존 WAC를 남기면 warning과 함께 잘못된 원가가 다시 노출될 수 있다. unusable 상태를 결과형에서 명시적으로 표현하고 두 원가 필드를 함께 `null`로 만드는 회귀가 필요하다. - `R-003` 기업행동 이후 BUY/SELL이 있으면 authoritative total도 거래와 함께 증감해야 한다. unit WAC와 quantity만 갱신하고 total을 고정하면 후속 거래가 있는 portfolio에서 잘못된 값을 반환하므로 running total과 unit WAC의 관계를 함께 검증해야 한다. ### Assumptions - `A-001` Issue #37에 기록된 정상 동작과 비범위가 이번 delivery의 제품 결정이다. Discovery confirmation에서 이 scope를 canonical하게 확정한다. - `A-002` repository가 제공하는 `executed_at ASC, ledger_entry_id ASC` 순서를 endpoint에서 재정렬하지 않고 그대로 replay한다.