fix(portfolio): tolerate consolidation WAC residue
This commit is contained in:
@@ -58,8 +58,8 @@ def test_release_version_and_migration_contract_has_current_head_0031():
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.3.1"' in project
|
||||
assert 'app_version: str = "0.3.1"' in config
|
||||
assert 'version = "0.3.2"' in project
|
||||
assert 'app_version: str = "0.3.2"' in config
|
||||
assert len(migrations) == 31
|
||||
assert migrations[-1].startswith("20260913_0031_")
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
"PortfolioHub v0.3.1 container contract",
|
||||
"PortfolioHub v0.3.2 container contract",
|
||||
"v0.2.1 includes migration `0030`",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
|
||||
@@ -16,3 +16,16 @@ def test_stock_consolidation_rejects_fractional_result():
|
||||
with pytest.raises(CorporateActionError) as exc:
|
||||
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
|
||||
assert exc.value.code == "FRACTIONAL_RESULT_UNSUPPORTED"
|
||||
|
||||
|
||||
def test_stock_consolidation_allows_stored_wac_rounding_residue():
|
||||
result = calculate_stock_consolidation(
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
)
|
||||
|
||||
assert result.average_price_before == Decimal("1086.23414594")
|
||||
assert result.average_price_after == Decimal("2172.46829187")
|
||||
assert result.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert result.total_cost_basis - result.average_price_after * result.quantity_after == Decimal("0.00000238")
|
||||
|
||||
@@ -19,6 +19,24 @@ def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
|
||||
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))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
@@ -36,7 +36,7 @@ def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypa
|
||||
|
||||
|
||||
def _action(asset_id="asset-1"):
|
||||
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(0), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
|
||||
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(2), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
|
||||
|
||||
|
||||
def _repo_for_row(asset_id):
|
||||
@@ -47,8 +47,8 @@ def _repo_for_row(asset_id):
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [(Decimal(0), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
|
||||
cur.fetchall.return_value = []
|
||||
cur.fetchone.side_effect = [(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
|
||||
cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))]
|
||||
return repo
|
||||
|
||||
|
||||
@@ -63,3 +63,60 @@ def test_record_corporate_action_rejects_different_asset_id():
|
||||
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
|
||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
|
||||
repo.record_corporate_action("pf-1", action=_action("87654321-4321-8765-4321-876543218765"), idempotency_key="idem", payload_hash="hash")
|
||||
|
||||
|
||||
def test_record_corporate_action_accepts_daekyo_rounding_residue():
|
||||
repo = object.__new__(PostgresPortfolioRepository)
|
||||
repo.ensure_schema = MagicMock()
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
repo._connect = MagicMock(return_value=conn)
|
||||
cur.fetchone.side_effect = [
|
||||
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
(7, datetime(2026, 9, 13, tzinfo=UTC)),
|
||||
]
|
||||
cur.fetchall.return_value = [
|
||||
(
|
||||
"buy",
|
||||
Decimal(1452),
|
||||
Decimal("1086.234145936639256198347107438016528926"),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
datetime(2026, 9, 9, tzinfo=UTC),
|
||||
)
|
||||
]
|
||||
action = CorporateAction(
|
||||
"ca-1",
|
||||
"pf-1",
|
||||
"asset-1",
|
||||
"stock_consolidation",
|
||||
date(2026, 9, 10),
|
||||
Decimal(1452),
|
||||
Decimal(726),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
Decimal(10),
|
||||
Decimal(0),
|
||||
"KIS 019685 2026-09-10",
|
||||
"idem-daekyo",
|
||||
"hash-daekyo",
|
||||
0,
|
||||
)
|
||||
|
||||
saved = repo.record_corporate_action(
|
||||
"pf-1",
|
||||
action=action,
|
||||
idempotency_key="idem-daekyo",
|
||||
payload_hash="hash-daekyo",
|
||||
)
|
||||
|
||||
assert saved.quantity_after == Decimal(726)
|
||||
assert saved.average_price_after == Decimal("2172.46829187")
|
||||
assert saved.total_cost_basis == Decimal("1577211.97990000")
|
||||
assert saved.ledger_entry_id == 7
|
||||
|
||||
Reference in New Issue
Block a user