fix(portfolios): replay corporate-action cost basis (#38)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: "ac-0038"
|
||||
status: active
|
||||
---
|
||||
|
||||
# 기업행동을 반영한 holdings 원가의 정본성과 불완전 metadata 차단
|
||||
|
||||
## Criterion
|
||||
|
||||
Portfolio owner 또는 허가된 daemon이 holdings cost-basis를 조회할 때, 시간순 ledger replay에 `stock_consolidation`이 있으면 응답은 기업행동 감사 기록의 양수 `quantity_after`를 현재 수량으로 적용하고 `total_cost_basis`를 authoritative 총원가로 보존하며 주당 원가를 `total_cost_basis / quantity_after`로 산출해야 한다. 필수값인 양수 `quantity_after` 또는 `total_cost_basis`가 없으면 기업행동 전 WAC나 snapshot 값으로 원가를 추정하지 않고 두 원가 필드를 `null`로 반환하며 기업행동 metadata 부족을 기존 warning 필드에서 구분해야 한다. 기업행동 이후 거래는 같은 replay에서 반영되고, 기업행동이 없는 기존 BUY/SELL 원가·warning·권한·응답 구조는 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: BUY/SELL 뒤 `1,452 → 726` consolidation을 구성하면 `current_quantity=726`, `total_cost_basis=1,577,211.97990000`, `cost_basis_per_unit=total_cost_basis/726`과 null warning을 반환한다. 저장된 8자리 `average_price_after`를 곱하여 총원가를 재구성하지 않으며, 후속 BUY/SELL 또는 기업행동이 있으면 정렬된 replay 결과가 현재 원가에 반영된다.
|
||||
- 실패·경계 조건: `quantity_after` 누락·0 이하 또는 `total_cost_basis` 누락에서는 `cost_basis_per_unit`과 `total_cost_basis`가 모두 null이고 기업행동 metadata 부족 warning이 non-null이어야 한다. 기업행동 전 WAC를 정상 원가로 노출하거나 BUY/SELL-only 결과, 기존 불완전 거래 warning, response field/nullability, authorization, cash·profit buffer를 변경하면 FAIL이다.
|
||||
- 검사 방법: domain unit test, holdings API fixture와 실제 PostgreSQL joined-ledger integration으로 metadata 전달·순서·정상/실패 결과를 검사한다. 기존 BUY/SELL·authorization·corporate-action atomicity 회귀와 전체 test suite, 정적 검사를 실행하고 기존 trex-core consumer가 정상 응답의 수량·단가·총원가 일관성을 통과하는지 별도 호환성 probe로 확인한다.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_line_number": 8,
|
||||
"next_ac_number": 38
|
||||
"next_line_number": 9,
|
||||
"next_ac_number": 39
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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한다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Activity Log
|
||||
|
||||
- 2026-09-19T11:36:22+09:00 — 사용자가 `dcy-0008`을 confirmed로 확정하고 exact `req-0008`·`ac-0038`을 승인했다. `req-0008`은 approved, `ac-0038`은 active로 전환했고 `proofline validate`가 통과했다(legacy line status warning만 존재).
|
||||
- 2026-09-19T11:36:22+09:00 — 구현 계획: API replay row에 기업행동 metadata를 보존하고, WAC replay에서 authoritative total과 post-action quantity를 적용하며, 필수 metadata 누락을 fail-closed로 처리한다. Domain/API/PostgreSQL/consumer 회귀와 기존 BUY/SELL 동작을 검증한다.
|
||||
- 2026-09-19T11:54:52+09:00 — 구현 및 검증: 기업행동 metadata 전달, `total_cost_basis / replay quantity` 계산, 누락 metadata의 원가 `null`/warning 처리를 구현했다. 표적 테스트 66건, 전체 테스트 631건(25 skip), PostgreSQL 기업행동 모듈 6건, Ruff ratchet, compileall, dependency check, ProofLine validation이 통과했다. metadata 전달을 제거한 sabotage에서 신규 API 회귀가 실패하고 복원 후 통과했다. `trex-core` 실제 reconciliation 경로는 `auto_synced`로 호환됨을 확인했다. 다음 단계는 독립 구현 리뷰와 PR delivery다.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
id: "line-0008"
|
||||
status: discovery
|
||||
---
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: "req-0008"
|
||||
status: approved
|
||||
discovery: "dcy-0008"
|
||||
criteria:
|
||||
create:
|
||||
- "ac-0038"
|
||||
update: []
|
||||
retire: []
|
||||
satisfy:
|
||||
- "ac-0037"
|
||||
---
|
||||
|
||||
# 기업행동 원가를 WAC replay에 반영 (#37)
|
||||
|
||||
## Objective
|
||||
|
||||
기업행동이 포함된 portfolio의 holdings cost-basis가 기업행동 감사 기록의 authoritative 총원가와 기업행동 후 수량을 사용하여 현재 원가를 반환하고, 계산에 필요한 metadata가 없을 때 병합 전 원가를 정상값처럼 노출하지 않도록 한다.
|
||||
|
||||
## Scope
|
||||
|
||||
- `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` replay에 repository가 결합한 `stock_consolidation` metadata를 전달한다.
|
||||
- 정상 기업행동은 `quantity_after`를 현재 replay 수량으로 적용하고 감사 기록의 `total_cost_basis`를 authoritative 총원가로 보존하며, 주당 원가는 `total_cost_basis / quantity_after`로 계산한다.
|
||||
- 기업행동 이후 BUY/SELL 또는 후속 기업행동이 있으면 같은 시간순 ledger replay 안에서 현재 수량·주당 원가·총원가를 계속 계산한다.
|
||||
- 필수 기업행동 metadata인 양수 `quantity_after` 또는 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않고 holdings 응답의 `cost_basis_per_unit`과 `total_cost_basis`를 `null`로 반환하며, 기존 warning 필드로 기업행동 metadata 부족을 구분한다.
|
||||
- 정상·불완전 기업행동, 기업행동 이후 거래, BUY/SELL-only, authorization, ordering과 consumer compatibility를 회귀 검증하고 interface contract를 동기화한다.
|
||||
|
||||
## Constraints
|
||||
|
||||
- 기업행동 감사 기록의 `total_cost_basis`가 원가 총액의 정본이다. Persisted `average_price_after`를 곱하여 총원가를 재구성하거나 별도의 허용오차 판정에 사용하지 않는다.
|
||||
- `HoldingsCostBasisResponse`의 기존 필드와 nullability를 유지하며 신규 reason field 또는 DB migration을 추가하지 않는다.
|
||||
- Repository의 `executed_at ASC, ledger_entry_id ASC` 순서, daemon scope와 portfolio ownership 검증을 유지한다.
|
||||
- 기업행동이 없는 BUY/SELL-only portfolio의 WAC 및 기존 불완전 거래 이력 warning 의미를 변경하지 않는다.
|
||||
- trex-core source와 검증 tolerance를 변경하지 않는다.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- `0.1%`, `1,000원 상한`, 소수점 8자리 일치 등 기업행동 원가의 추가 tolerance 정책.
|
||||
- Corporate-action write transaction, idempotency, recovery, cash, profit buffer, portfolio reference price 또는 기존 ledger row의 수정·backfill.
|
||||
- Broker 주문, trex-core slot 변경, B warning 삭제, production rollout 또는 특정 production portfolio 보정.
|
||||
+31
-10
@@ -1282,21 +1282,42 @@ def get_holdings_cost_basis(
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
# Build rows for domain function
|
||||
rows = [
|
||||
{
|
||||
"side": e.side,
|
||||
"quantity": e.quantity,
|
||||
"price": e.price,
|
||||
"fee_amount": e.fee_amount,
|
||||
"executed_at": e.executed_at,
|
||||
# Build rows for domain function, preserving joined corporate-action metadata.
|
||||
rows = []
|
||||
corporate_action_fields = (
|
||||
"corporate_action_id",
|
||||
"action_type",
|
||||
"quantity_before",
|
||||
"quantity_after",
|
||||
"average_price_after",
|
||||
"total_cost_basis",
|
||||
)
|
||||
for entry in ledger_entries:
|
||||
row = {
|
||||
"side": entry.side,
|
||||
"quantity": entry.quantity,
|
||||
"price": entry.price,
|
||||
"fee_amount": entry.fee_amount,
|
||||
"executed_at": entry.executed_at,
|
||||
"ledger_entry_id": entry.ledger_entry_id,
|
||||
}
|
||||
for e in ledger_entries
|
||||
]
|
||||
if entry.corporate_action:
|
||||
row.update(
|
||||
{
|
||||
field: entry.corporate_action.get(field)
|
||||
for field in corporate_action_fields
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
wac_result = calculate_current_wac(rows)
|
||||
|
||||
current_quantity = portfolio.asset_quantity
|
||||
if wac_result.total_cost_basis is not None and wac_result.current_quantity > 0:
|
||||
current_quantity = wac_result.current_quantity
|
||||
total_cost_basis = wac_result.total_cost_basis
|
||||
cost_basis_per_unit = total_cost_basis / current_quantity
|
||||
else:
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
|
||||
+59
-33
@@ -57,6 +57,9 @@ class CurrentWacResult:
|
||||
data_completeness_warning: str | None
|
||||
"""Non-None when ledger history is incomplete (sell before buy, oversell, etc.)."""
|
||||
|
||||
total_cost_basis: float | None = None
|
||||
"""Authoritative current total after a corporate action, when available."""
|
||||
|
||||
|
||||
def calculate_realized_pnl(
|
||||
rows: list[dict[str, Any]],
|
||||
@@ -204,29 +207,32 @@ def calculate_realized_pnl(
|
||||
def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
"""Calculate the current holding WAC (weighted average cost) from a time-ordered ledger.
|
||||
|
||||
Replays the full ledger history (buy/sell) and returns the running WAC state,
|
||||
representing the cost basis of currently held (unsold) shares.
|
||||
Replays the full ledger history (buy/sell/corporate action) and returns the
|
||||
running WAC state representing the cost basis of currently held shares.
|
||||
|
||||
Args:
|
||||
rows: List of portfolio_ledger row dicts, each containing:
|
||||
side (str), quantity (float), price (float),
|
||||
executed_at (datetime).
|
||||
Must be sorted by executed_at ASC.
|
||||
rows: Time-ordered portfolio-ledger dictionaries. BUY/SELL rows contain
|
||||
side, quantity, price, fee_amount and executed_at. A
|
||||
stock_consolidation row must also contain a positive
|
||||
quantity_after and authoritative total_cost_basis.
|
||||
Rows must be sorted by executed_at ASC (and ledger_entry_id ASC
|
||||
for ties) by the repository.
|
||||
|
||||
Returns:
|
||||
CurrentWacResult with:
|
||||
- current_wac: WAC per unit (None when current_quantity == 0)
|
||||
- current_quantity: remaining quantity after all trades
|
||||
- current_wac: WAC per unit (None for no holding or unusable
|
||||
corporate-action metadata)
|
||||
- current_quantity: remaining replay quantity
|
||||
- cost_basis_from: earliest buy date used
|
||||
- data_completeness_warning: non-None when history is incomplete
|
||||
- total_cost_basis: authoritative current total after a corporate
|
||||
action, otherwise None for the legacy BUY/SELL path
|
||||
|
||||
WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis):
|
||||
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
|
||||
running_qty += qty
|
||||
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
|
||||
sell: running_qty -= qty (WAC per unit unchanged on sell)
|
||||
Incomplete history (sell with running_qty=0, or sell qty > running_qty):
|
||||
data_completeness_warning is set; calculation continues with best estimate.
|
||||
WAC rules preserve the existing BUY/SELL behavior. A stock consolidation
|
||||
replaces the replay quantity with quantity_after, preserves
|
||||
total_cost_basis as authoritative, and derives unit cost by division.
|
||||
Missing required corporate-action metadata fails closed instead of falling
|
||||
back to the pre-action WAC.
|
||||
"""
|
||||
if not rows:
|
||||
return CurrentWacResult(
|
||||
@@ -240,6 +246,8 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
running_qty: float = 0.0
|
||||
cost_basis_from: datetime | None = None
|
||||
warning_triggered: bool = False
|
||||
corporate_action_metadata_missing: bool = False
|
||||
authoritative_total_cost_basis: float | None = None
|
||||
|
||||
for row in rows:
|
||||
side = str(row.get("side", "")).lower()
|
||||
@@ -252,38 +260,47 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
# Include buy fee in cost basis (매수 수수료 편입)
|
||||
new_total_qty = running_qty + qty
|
||||
if new_total_qty > 0:
|
||||
running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis += price * qty + fee
|
||||
running_wac = authoritative_total_cost_basis / new_total_qty
|
||||
else:
|
||||
running_wac = (
|
||||
running_wac * running_qty + price * qty + fee
|
||||
) / new_total_qty
|
||||
running_qty = new_total_qty
|
||||
if cost_basis_from is None and executed_at is not None:
|
||||
cost_basis_from = executed_at
|
||||
|
||||
elif side == "sell":
|
||||
if running_qty <= 0:
|
||||
warning_triggered = True
|
||||
elif qty > running_qty + 1e-9:
|
||||
if running_qty <= 0 or qty > running_qty + 1e-9:
|
||||
warning_triggered = True
|
||||
|
||||
running_qty -= qty
|
||||
if running_qty < 0:
|
||||
running_qty = 0.0
|
||||
if authoritative_total_cost_basis is not None:
|
||||
authoritative_total_cost_basis = running_wac * running_qty
|
||||
|
||||
elif side == "stock_consolidation":
|
||||
before = float(row.get("quantity_before", running_qty))
|
||||
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
||||
if before <= 0 or after <= 0 or running_qty <= 0:
|
||||
warning_triggered = True
|
||||
continue
|
||||
if abs(running_qty - before) > 1e-9:
|
||||
warning_triggered = True
|
||||
running_qty = after
|
||||
quantity_after = row.get("quantity_after")
|
||||
total_cost_basis = row.get("total_cost_basis")
|
||||
if total_cost_basis is not None:
|
||||
running_wac = float(total_cost_basis) / after
|
||||
else:
|
||||
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
||||
if quantity_after is None or total_cost_basis is None:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
after = float(quantity_after)
|
||||
if after <= 0:
|
||||
corporate_action_metadata_missing = True
|
||||
continue
|
||||
running_qty = after
|
||||
authoritative_total_cost_basis = float(total_cost_basis)
|
||||
running_wac = authoritative_total_cost_basis / after
|
||||
|
||||
warning: str | None = None
|
||||
if warning_triggered:
|
||||
if corporate_action_metadata_missing:
|
||||
warning = (
|
||||
"기업행동 metadata 불완전: 양수 quantity_after와 total_cost_basis가 필요합니다."
|
||||
)
|
||||
elif warning_triggered:
|
||||
warning = (
|
||||
"이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 "
|
||||
"cost_basis 산출이 부정확할 수 있습니다. "
|
||||
@@ -291,10 +308,19 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
||||
)
|
||||
|
||||
return CurrentWacResult(
|
||||
current_wac=running_wac if running_qty > 0 else None,
|
||||
current_wac=(
|
||||
running_wac
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
current_quantity=running_qty,
|
||||
cost_basis_from=cost_basis_from,
|
||||
data_completeness_warning=warning,
|
||||
total_cost_basis=(
|
||||
authoritative_total_cost_basis
|
||||
if running_qty > 0 and not corporate_action_metadata_missing
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -445,6 +445,14 @@
|
||||
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history.
|
||||
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied. `--resume-partial` enables only the guarded recovery mode above.
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` (REQ-043, req-0008)
|
||||
|
||||
- BUY/SELL과 `stock_consolidation` ledger를 repository가 제공한 `executed_at ASC, ledger_entry_id ASC` 순서대로 replay한다.
|
||||
- 정상 `stock_consolidation`은 감사 metadata의 양수 `quantity_after`를 replay 수량으로 적용하고 `total_cost_basis`를 authoritative 총원가로 보존한다. 응답의 `cost_basis_per_unit`는 authoritative 총원가를 현재 수량으로 나눈 값이다.
|
||||
- Persisted `average_price_after`는 감사·표시용 반올림 값이다. Endpoint는 이를 수량과 곱하여 총원가를 재구성하거나 별도 허용오차 판정에 사용하지 않는다.
|
||||
- `quantity_after`가 없거나 0 이하이거나 `total_cost_basis`가 없으면 기업행동 전 WAC로 추정하지 않는다. `cost_basis_per_unit`과 `total_cost_basis`는 모두 `null`이고 `data_completeness_warning`은 기업행동 metadata 부족을 나타낸다.
|
||||
- 기업행동이 없는 BUY/SELL-only 원가와 기존 불완전 거래 이력 warning, response field/nullability, daemon scope와 portfolio ownership 계약은 유지한다.
|
||||
|
||||
|
||||
### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024)
|
||||
|
||||
|
||||
@@ -177,6 +177,12 @@ def test_joined_ledger_reads_qualify_base_columns(repository):
|
||||
assert len(ledger_entries) == 2
|
||||
assert any(entry.corporate_action for entry in pnl_entries)
|
||||
assert any(entry.corporate_action for entry in ledger_entries)
|
||||
assert [entry.side for entry in pnl_entries] == ["buy", "stock_consolidation"]
|
||||
corporate_entry = pnl_entries[1]
|
||||
assert corporate_entry.corporate_action is not None
|
||||
assert corporate_entry.corporate_action["quantity_before"] == Decimal("1452.00000000")
|
||||
assert corporate_entry.corporate_action["quantity_after"] == Decimal("726.00000000")
|
||||
assert corporate_entry.corporate_action["total_cost_basis"] == _TOTAL_COST
|
||||
assert ledger_entries[0].recorded_at >= ledger_entries[1].recorded_at
|
||||
|
||||
corporate_entries = repository.list_ledger_entries(_PORTFOLIO_ID, source="corporate_action", limit=50)
|
||||
|
||||
@@ -2,6 +2,8 @@ from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.corporate_actions import canonical_payload_hash
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.schemas.corporate_actions import CorporateActionRequest
|
||||
@@ -10,7 +12,7 @@ from app.schemas.corporate_actions import CorporateActionRequest
|
||||
def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=UTC)},
|
||||
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=UTC)},
|
||||
{"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "total_cost_basis": 1000, "executed_at": datetime(2026, 1, 2, tzinfo=UTC)},
|
||||
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=UTC)},
|
||||
]
|
||||
assert calculate_current_wac(rows).current_wac == 200
|
||||
@@ -35,6 +37,59 @@ def test_consolidation_replay_uses_preserved_total_cost_basis():
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 726
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
assert result.total_cost_basis == float(Decimal("1577211.97990000"))
|
||||
assert result.data_completeness_warning is None
|
||||
|
||||
|
||||
def test_consolidation_replay_ignores_rounded_average_price_after():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 1452, "price": 1086.2341459366393},
|
||||
{
|
||||
"side": "stock_consolidation",
|
||||
"quantity_after": 726,
|
||||
"average_price_after": Decimal("1.00"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(726))
|
||||
assert result.total_cost_basis == float(Decimal("1577211.97990000"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
{"total_cost_basis": Decimal(1000)},
|
||||
{"quantity_after": 0, "total_cost_basis": Decimal(1000)},
|
||||
{"quantity_after": 5},
|
||||
],
|
||||
)
|
||||
def test_consolidation_replay_fails_closed_when_required_metadata_is_missing(metadata):
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100},
|
||||
{"side": "stock_consolidation", **metadata},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_wac is None
|
||||
assert result.total_cost_basis is None
|
||||
assert result.data_completeness_warning is not None
|
||||
assert "기업행동 metadata" in result.data_completeness_warning
|
||||
|
||||
|
||||
def test_consolidation_authoritative_total_continues_through_later_trades():
|
||||
rows = [
|
||||
{"side": "buy", "quantity": 10, "price": 100},
|
||||
{"side": "stock_consolidation", "quantity_after": 5, "total_cost_basis": Decimal(1000)},
|
||||
{"side": "buy", "quantity": 1, "price": 300, "fee_amount": 2},
|
||||
{"side": "sell", "quantity": 2, "price": 400},
|
||||
]
|
||||
|
||||
result = calculate_current_wac(rows)
|
||||
assert result.current_quantity == 4
|
||||
assert result.current_wac == pytest.approx(1302 / 6)
|
||||
assert result.total_cost_basis == pytest.approx((1302 / 6) * 4)
|
||||
|
||||
|
||||
def test_canonical_hash_same_payload_and_changed_payload_differ():
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import UTC, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -286,12 +287,12 @@ class TestAC2PartialSellWacUnchanged:
|
||||
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0)
|
||||
|
||||
|
||||
def test_corporate_action_row_keeps_holdings_cost_basis_http_200() -> None:
|
||||
def test_corporate_action_row_returns_authoritative_holdings_cost_basis() -> None:
|
||||
buy = _make_entry(
|
||||
ledger_entry_id=1,
|
||||
side="buy",
|
||||
quantity=20,
|
||||
price=100,
|
||||
quantity=1452,
|
||||
price=1086.2341459366393,
|
||||
executed_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
corporate_action = LedgerEntry(
|
||||
@@ -299,13 +300,21 @@ def test_corporate_action_row_keeps_holdings_cost_basis_http_200() -> None:
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
source="corporate_action",
|
||||
side="stock_consolidation",
|
||||
quantity=10,
|
||||
price=200,
|
||||
quantity=726,
|
||||
price=2172.46829187,
|
||||
recorded_at=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
corporate_action={"action_type": "stock_consolidation"},
|
||||
corporate_action={
|
||||
"corporate_action_id": "ca-019685",
|
||||
"action_type": "stock_consolidation",
|
||||
"quantity_before": Decimal(1452),
|
||||
"quantity_after": Decimal(726),
|
||||
"average_price_after": Decimal("2172.46829187"),
|
||||
"total_cost_basis": Decimal("1577211.97990000"),
|
||||
},
|
||||
)
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=10),
|
||||
# Corporate-action replay is authoritative for the returned quantity.
|
||||
portfolio=_make_portfolio(symbol="019685", asset_quantity=999),
|
||||
trades=[buy, corporate_action],
|
||||
)
|
||||
|
||||
@@ -316,8 +325,61 @@ def test_corporate_action_row_keeps_holdings_cost_basis_http_200() -> None:
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["cost_basis_per_unit"] == pytest.approx(100)
|
||||
assert response.json()["total_cost_basis"] == pytest.approx(1000)
|
||||
body = response.json()
|
||||
assert body["current_quantity"] == pytest.approx(726)
|
||||
assert body["cost_basis_per_unit"] == 1577211.9799 / 726
|
||||
assert body["total_cost_basis"] == 1577211.9799
|
||||
assert body["data_completeness_warning"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"corporate_action",
|
||||
[
|
||||
{"action_type": "stock_consolidation", "total_cost_basis": Decimal(1000)},
|
||||
{
|
||||
"action_type": "stock_consolidation",
|
||||
"quantity_after": Decimal(0),
|
||||
"total_cost_basis": Decimal(1000),
|
||||
},
|
||||
{"action_type": "stock_consolidation", "quantity_after": Decimal(10)},
|
||||
],
|
||||
)
|
||||
def test_corporate_action_missing_required_metadata_returns_null_costs(
|
||||
corporate_action: dict[str, object],
|
||||
) -> None:
|
||||
buy = _make_entry(
|
||||
ledger_entry_id=1,
|
||||
side="buy",
|
||||
quantity=20,
|
||||
price=100,
|
||||
executed_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
action = LedgerEntry(
|
||||
ledger_entry_id=2,
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
source="corporate_action",
|
||||
side="stock_consolidation",
|
||||
quantity=10,
|
||||
price=200,
|
||||
recorded_at=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
corporate_action=corporate_action,
|
||||
)
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=10),
|
||||
trades=[buy, action],
|
||||
)
|
||||
|
||||
with _with_repo(repo):
|
||||
response = _make_client(_make_settings()).get(
|
||||
_PATH,
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["cost_basis_per_unit"] is None
|
||||
assert body["total_cost_basis"] is None
|
||||
assert "기업행동 metadata" in body["data_completeness_warning"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user