131 lines
5.7 KiB
Python
131 lines
5.7 KiB
Python
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
|
|
|
|
|
|
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, "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
|
|
result = calculate_realized_pnl(rows)
|
|
assert result.items[0].cost_basis_per_unit == 200
|
|
assert result.total_realized_pnl == 100
|
|
|
|
|
|
def test_consolidation_replay_uses_preserved_total_cost_basis():
|
|
rows = [
|
|
{"side": "buy", "quantity": 1452, "price": 1086.2341459366393, "fee_amount": 0, "executed_at": datetime(2026, 9, 9, tzinfo=UTC)},
|
|
{
|
|
"side": "stock_consolidation",
|
|
"quantity_before": 1452,
|
|
"quantity_after": 726,
|
|
"average_price_after": Decimal("2172.46829187"),
|
|
"total_cost_basis": Decimal("1577211.97990000"),
|
|
"executed_at": datetime(2026, 9, 10, tzinfo=UTC),
|
|
},
|
|
]
|
|
|
|
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():
|
|
a = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(5), evidence_ref="ev")
|
|
b = CorporateActionRequest(effective_date=date(2026, 1, 2), quantity_before=Decimal(10), quantity_after=Decimal(4), evidence_ref="ev")
|
|
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
|
|
assert canonical_payload_hash(a) != canonical_payload_hash(b)
|
|
|
|
|
|
def test_default_resume_flag_preserves_pre_recovery_idempotency_hash():
|
|
request = CorporateActionRequest(
|
|
effective_date=date(2026, 1, 2),
|
|
quantity_before=Decimal(10),
|
|
quantity_after=Decimal(5),
|
|
evidence_ref="ev",
|
|
)
|
|
legacy_payload = request.model_dump(mode="json", exclude={"resume_partial"})
|
|
explicit_default_payload = {**legacy_payload, "resume_partial": False}
|
|
|
|
assert canonical_payload_hash(request) == canonical_payload_hash(legacy_payload)
|
|
assert canonical_payload_hash(explicit_default_payload) == canonical_payload_hash(legacy_payload)
|
|
assert canonical_payload_hash(
|
|
request.model_copy(update={"resume_partial": True})
|
|
) != canonical_payload_hash(legacy_payload)
|
|
|
|
|
|
def test_runtime_schema_has_one_corporate_action_ddl_and_replay_mapping_starts_at_18():
|
|
source = Path("app/repositories/postgres.py").read_text()
|
|
assert source.count("CREATE TABLE IF NOT EXISTS portfolio_corporate_actions") == 1
|
|
assert "ledger.strategy_set_id, ledger.note, ledger.source," in source
|
|
assert "ca.corporate_action_id, ca.action_type" in source
|
|
assert 'row[18] is not None' in source
|
|
|
|
|
|
def test_joined_corporate_action_metadata_is_complete_in_mapping_contract():
|
|
source = Path("app/repositories/postgres.py").read_text()
|
|
for field in ("action_type", "quantity_before", "quantity_after", "average_price_before", "average_price_after", "total_cost_basis", "cash_balance", "profit_buffer", "evidence_ref", "actor_user_id", "idempotency_key", "payload_hash", "recorded_at"):
|
|
assert f'"{field}"' in source
|