From 2b470c735fc08678f10172098870d3549350cf99 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 13 Sep 2026 18:36:00 +0900 Subject: [PATCH] fix(portfolio): tolerate consolidation WAC residue --- .github/workflows/publish-image.yml | 3 +- .github/workflows/verify.yml | 3 +- app/core/config.py | 2 +- app/domain/corporate_actions.py | 19 ++++++-- app/domain/pnl.py | 12 ++++- app/repositories/postgres.py | 14 ++++-- docs/container-operations.md | 20 ++++---- docs/interface-contract.md | 1 + pyproject.toml | 2 +- tests/release/test_release_contracts.py | 6 +-- tests/test_corporate_actions.py | 13 +++++ tests/test_issue34_regressions.py | 18 +++++++ tests/test_issue34_runtime_blockers.py | 65 +++++++++++++++++++++++-- uv.lock | 2 +- 14 files changed, 146 insertions(+), 34 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index fa015ae..30a7a1c 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -42,10 +42,11 @@ jobs: - run: uv run pytest -q - name: Honest scoped lint run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release - - name: Reject Ruff findings introduced by v0.3.0 or its patch + - name: Reject Ruff findings introduced by v0.3.0 or later patches run: | uv run python scripts/ruff_ratchet.py --base v0.2.1 uv run python scripts/ruff_ratchet.py --base v0.3.0 + uv run python scripts/ruff_ratchet.py --base v0.3.1 - name: Record exact source migration inventory run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt - name: Create excluded synthetic canary diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b2f309d..b451724 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -25,10 +25,11 @@ jobs: - run: uv run pytest -q - name: Honest scoped lint for release additions run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release - - name: Reject Ruff findings introduced by v0.3.0 or its patch + - name: Reject Ruff findings introduced by v0.3.0 or later patches run: | uv run python scripts/ruff_ratchet.py --base v0.2.1 uv run python scripts/ruff_ratchet.py --base v0.3.0 + uv run python scripts/ruff_ratchet.py --base v0.3.1 - name: Validate container contracts without registry writes run: uv run pytest -q tests/release - name: Build and smoke exact pull-request or main source image diff --git a/app/core/config.py b/app/core/config.py index c07a9ed..d3aba24 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -18,7 +18,7 @@ if env_file.exists(): class Settings(BaseSettings): app_name: str = "PortfolioHub" - app_version: str = "0.3.1" + app_version: str = "0.3.2" environment: str = "development" allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS") bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME") diff --git a/app/domain/corporate_actions.py b/app/domain/corporate_actions.py index a128380..d96bb3b 100644 --- a/app/domain/corporate_actions.py +++ b/app/domain/corporate_actions.py @@ -6,6 +6,13 @@ import json from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal +STORAGE_QUANTUM = Decimal("0.00000001") + + +def quantize_stored_decimal(value: Decimal) -> Decimal: + """Round a monetary value to the NUMERIC(*, 8) persistence contract.""" + return value.quantize(STORAGE_QUANTUM, rounding=ROUND_HALF_UP) + def canonical_payload_hash(payload: object) -> str: if hasattr(payload, "model_dump"): @@ -36,11 +43,13 @@ def calculate_stock_consolidation(quantity_before: Decimal, quantity_after: Deci raise CorporateActionError("stock consolidation must reduce quantity") if q1 != q1.to_integral_value() or q0 != q0.to_integral_value(): raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED") - total = (q0 * wac).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) - after = (total / q1).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) - if after * q1 != total: - raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED") - return StockConsolidation(q0, q1, wac, after, total, ratio) + # Preserve the authoritative pre-action total independently. A per-unit WAC + # rounded to the storage scale cannot always multiply back to that total + # exactly, and that harmless decimal residue is not a fractional share. + total = quantize_stored_decimal(q0 * wac) + before = quantize_stored_decimal(wac) + after = quantize_stored_decimal(total / q1) + return StockConsolidation(q0, q1, before, after, total, ratio) def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]: result = calculate_stock_consolidation(quantity, quantity_after, wac) diff --git a/app/domain/pnl.py b/app/domain/pnl.py index 85d65a4..06bc4e6 100644 --- a/app/domain/pnl.py +++ b/app/domain/pnl.py @@ -179,7 +179,11 @@ def calculate_realized_pnl( if abs(running_qty - before) > 1e-9: warning_triggered = True running_qty = after - running_wac = float(row.get("average_price_after", running_wac * before / 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)) warning: str | None = None if warning_triggered: @@ -272,7 +276,11 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult: if abs(running_qty - before) > 1e-9: warning_triggered = True running_qty = after - running_wac = float(row.get("average_price_after", running_wac * before / 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)) warning: str | None = None if warning_triggered: diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index edca2e8..67f3f5e 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -14,6 +14,7 @@ from typing import Any import psycopg from psycopg import sql +from app.domain.corporate_actions import calculate_stock_consolidation from app.domain.errors import ( DaemonInactiveError, DaemonInUseError, @@ -1668,11 +1669,14 @@ class PostgresPortfolioRepository: wac = calculate_current_wac(history) if Decimal(str(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before: raise ValueError("CORPORATE_ACTION_STALE_QUANTITY") - action.average_price_before = Decimal(str(wac.current_wac or 0)) - action.total_cost_basis = (action.average_price_before * action.quantity_before).quantize(Decimal("0.00000001")) - action.average_price_after = (action.total_cost_basis / action.quantity_after).quantize(Decimal("0.00000001")) - if action.average_price_after * action.quantity_after != action.total_cost_basis: - raise ValueError("FRACTIONAL_RESULT_UNSUPPORTED") + consolidation = calculate_stock_consolidation( + action.quantity_before, + action.quantity_after, + Decimal(str(wac.current_wac or 0)), + ) + action.average_price_before = consolidation.average_price_before + action.average_price_after = consolidation.average_price_after + action.total_cost_basis = consolidation.total_cost_basis cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key)) prior = cur.fetchone() if prior: diff --git a/docs/container-operations.md b/docs/container-operations.md index 8a8b1d9..1037a8b 100644 --- a/docs/container-operations.md +++ b/docs/container-operations.md @@ -1,11 +1,11 @@ -# PortfolioHub v0.3.1 container contract +# PortfolioHub v0.3.2 container contract ## Publication is not deployment -Only a requester-authorized push of the strict tag `v0.3.1` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors: +Only a requester-authorized push of the strict tag `v0.3.2` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors: - `ghcr.io/fourblocks2020/portfolio-hub:` -- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.1` +- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.2` It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity: @@ -13,9 +13,9 @@ It never publishes `latest`. Both selectors must report the same registry digest ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest> ``` -OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.3.1`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration. +OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.3.2`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration. -The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.3.1 supply-chain gap; they must not be added by rebuilding a different image in the publish job. +The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.3.2 supply-chain gap; they must not be added by rebuilding a different image in the publish job. ## Image runtime @@ -66,19 +66,19 @@ Values are read at process start; changing any value requires a container restar | `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start | | `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata | | `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name | -| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.3.1 | `0.3.1` | no | health/OpenAPI version metadata | +| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.3.2 | `0.3.2` | no | health/OpenAPI version metadata | -`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.3.1 application does not open Redis or Loki connections. They are not production dependencies of this image. +`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.3.2 application does not open Redis or Loki connections. They are not production dependencies of this image. ### Legacy and sibling mapping -The existing database variable names remain `TIMESCALEDB_*`; v0.3.1 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container. +The existing database variable names remain `TIMESCALEDB_*`; v0.3.2 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container. ## Liveness and readiness - **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness. - **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch. -- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.1`. +- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.2`. Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations. @@ -107,7 +107,7 @@ Image rollback is **not** database rollback. Selecting older application bytes d ## API verification and known caller blockers -v0.3.1 preserves Bearer authentication, retains all-source unified-ledger reads, and adds stock-consolidation corporate-action recording and replay. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against both v0.2.1 (the reported v0.3.0 scope) and v0.3.0 (the patch scope), and rejects every newly introduced finding. +v0.3.2 preserves Bearer authentication and all-source unified-ledger reads. It records integral-share stock consolidations even when 8-decimal WAC storage creates a harmless recomputation residue: WAC is rounded half-up to 8 decimals, while the authoritative total cost basis remains independently stored and drives replay. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 31-file migration inventory, isolated PostgreSQL migration twice, readiness at `0031`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. The changed-scope Ruff gate uses the locked linter and the ACMR Python path selector, compares normalized findings against v0.2.1, v0.3.0, and v0.3.1, and rejects every newly introduced finding. The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 346b2dc..f63d300 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -438,6 +438,7 @@ - Auth: same owner-scoped user/session token policy as manual write. - Query: `limit` 1..200, default 50. - `GET /v1/portfolios/{id}/ledger` returns corporate-action events as a distinct `CorporateActionLedgerHistoryItem` with event ID/type, effective date, before/after quantity and WAC, total cost basis, cash/profit buffer, evidence, actor, and recorded time. These events are never represented as BUY/SELL trades. +- For stock consolidations, WAC fields are rounded half-up to the persisted 8-decimal scale. `total_cost_basis` is preserved independently as the authoritative pre-action acquisition cost; a sub-cent residue when `average_price_after * quantity_after` is recomputed does not make an integral-share consolidation invalid. - `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. diff --git a/pyproject.toml b/pyproject.toml index 909c708..5651d59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "portfoliohub" -version = "0.3.1" +version = "0.3.2" description = "PortfolioHub API for portfolio management and ledger access" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 3ec4942..a328a89 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -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`", ): diff --git a/tests/test_corporate_actions.py b/tests/test_corporate_actions.py index 3a28cc0..e508c40 100644 --- a/tests/test_corporate_actions.py +++ b/tests/test_corporate_actions.py @@ -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") diff --git a/tests/test_issue34_regressions.py b/tests/test_issue34_regressions.py index 2a62330..7191232 100644 --- a/tests/test_issue34_regressions.py +++ b/tests/test_issue34_regressions.py @@ -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") diff --git a/tests/test_issue34_runtime_blockers.py b/tests/test_issue34_runtime_blockers.py index b129cad..a6e6a77 100644 --- a/tests/test_issue34_runtime_blockers.py +++ b/tests/test_issue34_runtime_blockers.py @@ -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 diff --git a/uv.lock b/uv.lock index 1faf026..7addf88 100644 --- a/uv.lock +++ b/uv.lock @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "portfoliohub" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "alembic" },