fix(portfolio): tolerate consolidation WAC residue

This commit is contained in:
2026-09-13 18:36:00 +09:00
parent 4520fbbfbd
commit 8126ba7704
14 changed files with 146 additions and 34 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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")
+14 -5
View File
@@ -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)
+10 -2
View File
@@ -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:
+9 -5
View File
@@ -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:
+10 -10
View File
@@ -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:<source-commit-first-12>`
- `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.
+1
View File
@@ -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.
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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`",
):
+13
View File
@@ -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")
+18
View File
@@ -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")
+61 -4
View File
@@ -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
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]]
name = "portfoliohub"
version = "0.3.1"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },