fix(portfolios): replay corporate-action cost basis (#38)
This commit is contained in:
@@ -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