fix(portfolios): replay corporate-action cost basis (#38)

This commit is contained in:
2026-09-19 17:07:14 +09:00
committed by GitHub
parent f79f3c79b5
commit 567d662913
12 changed files with 352 additions and 61 deletions
+16
View File
@@ -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로 확인한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"schema_version": 1, "schema_version": 1,
"next_line_number": 8, "next_line_number": 9,
"next_ac_number": 38 "next_ac_number": 39
} }
+48
View File
@@ -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다.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0008"
status: discovery
---
+40
View File
@@ -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 보정.
+37 -16
View File
@@ -1282,27 +1282,48 @@ def get_holdings_cost_basis(
finally: finally:
_close_repo(repo) _close_repo(repo)
# Build rows for domain function # Build rows for domain function, preserving joined corporate-action metadata.
rows = [ rows = []
{ corporate_action_fields = (
"side": e.side, "corporate_action_id",
"quantity": e.quantity, "action_type",
"price": e.price, "quantity_before",
"fee_amount": e.fee_amount, "quantity_after",
"executed_at": e.executed_at, "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) wac_result = calculate_current_wac(rows)
current_quantity = portfolio.asset_quantity current_quantity = portfolio.asset_quantity
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None if wac_result.total_cost_basis is not None and wac_result.current_quantity > 0:
total_cost_basis = ( current_quantity = wac_result.current_quantity
cost_basis_per_unit * current_quantity total_cost_basis = wac_result.total_cost_basis
if cost_basis_per_unit is not None and current_quantity > 0 cost_basis_per_unit = total_cost_basis / current_quantity
else None else:
) cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
total_cost_basis = (
cost_basis_per_unit * current_quantity
if cost_basis_per_unit is not None and current_quantity > 0
else None
)
return HoldingsCostBasisResponse( return HoldingsCostBasisResponse(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,
+59 -33
View File
@@ -57,6 +57,9 @@ class CurrentWacResult:
data_completeness_warning: str | None data_completeness_warning: str | None
"""Non-None when ledger history is incomplete (sell before buy, oversell, etc.).""" """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( def calculate_realized_pnl(
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
@@ -204,29 +207,32 @@ def calculate_realized_pnl(
def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult: def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
"""Calculate the current holding WAC (weighted average cost) from a time-ordered ledger. """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, Replays the full ledger history (buy/sell/corporate action) and returns the
representing the cost basis of currently held (unsold) shares. running WAC state representing the cost basis of currently held shares.
Args: Args:
rows: List of portfolio_ledger row dicts, each containing: rows: Time-ordered portfolio-ledger dictionaries. BUY/SELL rows contain
side (str), quantity (float), price (float), side, quantity, price, fee_amount and executed_at. A
executed_at (datetime). stock_consolidation row must also contain a positive
Must be sorted by executed_at ASC. 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: Returns:
CurrentWacResult with: CurrentWacResult with:
- current_wac: WAC per unit (None when current_quantity == 0) - current_wac: WAC per unit (None for no holding or unusable
- current_quantity: remaining quantity after all trades corporate-action metadata)
- current_quantity: remaining replay quantity
- cost_basis_from: earliest buy date used - cost_basis_from: earliest buy date used
- data_completeness_warning: non-None when history is incomplete - 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): WAC rules preserve the existing BUY/SELL behavior. A stock consolidation
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty) replaces the replay quantity with quantity_after, preserves
running_qty += qty total_cost_basis as authoritative, and derives unit cost by division.
(buy_fee = fee_amount of the buy row, incorporated into cost basis) Missing required corporate-action metadata fails closed instead of falling
sell: running_qty -= qty (WAC per unit unchanged on sell) back to the pre-action WAC.
Incomplete history (sell with running_qty=0, or sell qty > running_qty):
data_completeness_warning is set; calculation continues with best estimate.
""" """
if not rows: if not rows:
return CurrentWacResult( return CurrentWacResult(
@@ -240,6 +246,8 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
running_qty: float = 0.0 running_qty: float = 0.0
cost_basis_from: datetime | None = None cost_basis_from: datetime | None = None
warning_triggered: bool = False warning_triggered: bool = False
corporate_action_metadata_missing: bool = False
authoritative_total_cost_basis: float | None = None
for row in rows: for row in rows:
side = str(row.get("side", "")).lower() 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 (매수 수수료 편입) # Include buy fee in cost basis (매수 수수료 편입)
new_total_qty = running_qty + qty new_total_qty = running_qty + qty
if new_total_qty > 0: 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 running_qty = new_total_qty
if cost_basis_from is None and executed_at is not None: if cost_basis_from is None and executed_at is not None:
cost_basis_from = executed_at cost_basis_from = executed_at
elif side == "sell": elif side == "sell":
if running_qty <= 0: if running_qty <= 0 or qty > running_qty + 1e-9:
warning_triggered = True
elif qty > running_qty + 1e-9:
warning_triggered = True warning_triggered = True
running_qty -= qty running_qty -= qty
if running_qty < 0: if running_qty < 0:
running_qty = 0.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": elif side == "stock_consolidation":
before = float(row.get("quantity_before", running_qty)) quantity_after = row.get("quantity_after")
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
total_cost_basis = row.get("total_cost_basis") total_cost_basis = row.get("total_cost_basis")
if total_cost_basis is not None: if quantity_after is None or total_cost_basis is None:
running_wac = float(total_cost_basis) / after corporate_action_metadata_missing = True
else: continue
running_wac = float(row.get("average_price_after", running_wac * before / after)) 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 warning: str | None = None
if warning_triggered: if corporate_action_metadata_missing:
warning = (
"기업행동 metadata 불완전: 양수 quantity_after와 total_cost_basis가 필요합니다."
)
elif warning_triggered:
warning = ( warning = (
"이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 " "이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 "
"cost_basis 산출이 부정확할 수 있습니다. " "cost_basis 산출이 부정확할 수 있습니다. "
@@ -291,10 +308,19 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
) )
return 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, current_quantity=running_qty,
cost_basis_from=cost_basis_from, cost_basis_from=cost_basis_from,
data_completeness_warning=warning, data_completeness_warning=warning,
total_cost_basis=(
authoritative_total_cost_basis
if running_qty > 0 and not corporate_action_metadata_missing
else None
),
) )
+8
View File
@@ -445,6 +445,14 @@
- `GET /v1/portfolios/{id}/trades` remains restricted to BUY/SELL history. - `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. - `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) ### 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 len(ledger_entries) == 2
assert any(entry.corporate_action for entry in pnl_entries) assert any(entry.corporate_action for entry in pnl_entries)
assert any(entry.corporate_action for entry in ledger_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 assert ledger_entries[0].recorded_at >= ledger_entries[1].recorded_at
corporate_entries = repository.list_ledger_entries(_PORTFOLIO_ID, source="corporate_action", limit=50) corporate_entries = repository.list_ledger_entries(_PORTFOLIO_ID, source="corporate_action", limit=50)
+56 -1
View File
@@ -2,6 +2,8 @@ from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest
from app.domain.corporate_actions import canonical_payload_hash from app.domain.corporate_actions import canonical_payload_hash
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.schemas.corporate_actions import CorporateActionRequest 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(): def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
rows = [ rows = [
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, tzinfo=UTC)}, {"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)}, {"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 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) result = calculate_current_wac(rows)
assert result.current_quantity == 726 assert result.current_quantity == 726
assert result.current_wac == float(Decimal("1577211.97990000") / Decimal(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(): def test_canonical_hash_same_payload_and_changed_payload_differ():
+71 -9
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from contextlib import ExitStack from contextlib import ExitStack
from datetime import UTC, datetime, timezone from datetime import UTC, datetime, timezone
from decimal import Decimal
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -286,12 +287,12 @@ class TestAC2PartialSellWacUnchanged:
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0) 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( buy = _make_entry(
ledger_entry_id=1, ledger_entry_id=1,
side="buy", side="buy",
quantity=20, quantity=1452,
price=100, price=1086.2341459366393,
executed_at=datetime(2026, 9, 1, tzinfo=UTC), executed_at=datetime(2026, 9, 1, tzinfo=UTC),
) )
corporate_action = LedgerEntry( corporate_action = LedgerEntry(
@@ -299,13 +300,21 @@ def test_corporate_action_row_keeps_holdings_cost_basis_http_200() -> None:
portfolio_id=_PORTFOLIO_ID, portfolio_id=_PORTFOLIO_ID,
source="corporate_action", source="corporate_action",
side="stock_consolidation", side="stock_consolidation",
quantity=10, quantity=726,
price=200, price=2172.46829187,
recorded_at=datetime(2026, 9, 2, tzinfo=UTC), 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( 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], 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.status_code == 200
assert response.json()["cost_basis_per_unit"] == pytest.approx(100) body = response.json()
assert response.json()["total_cost_basis"] == pytest.approx(1000) 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"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------