fix(portfolio): tolerate consolidation WAC residue
This commit is contained in:
@@ -42,10 +42,11 @@ jobs:
|
|||||||
- run: uv run pytest -q
|
- run: uv run pytest -q
|
||||||
- name: Honest scoped lint
|
- 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
|
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: |
|
run: |
|
||||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
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.0
|
||||||
|
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||||
- name: Record exact source migration inventory
|
- 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
|
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
|
- name: Create excluded synthetic canary
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ jobs:
|
|||||||
- run: uv run pytest -q
|
- run: uv run pytest -q
|
||||||
- name: Honest scoped lint for release additions
|
- 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
|
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: |
|
run: |
|
||||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
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.0
|
||||||
|
uv run python scripts/ruff_ratchet.py --base v0.3.1
|
||||||
- name: Validate container contracts without registry writes
|
- name: Validate container contracts without registry writes
|
||||||
run: uv run pytest -q tests/release
|
run: uv run pytest -q tests/release
|
||||||
- name: Build and smoke exact pull-request or main source image
|
- name: Build and smoke exact pull-request or main source image
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
app_name: str = "PortfolioHub"
|
app_name: str = "PortfolioHub"
|
||||||
app_version: str = "0.3.1"
|
app_version: str = "0.3.2"
|
||||||
environment: str = "development"
|
environment: str = "development"
|
||||||
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
||||||
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
|
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import json
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
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:
|
def canonical_payload_hash(payload: object) -> str:
|
||||||
if hasattr(payload, "model_dump"):
|
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")
|
raise CorporateActionError("stock consolidation must reduce quantity")
|
||||||
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
|
if q1 != q1.to_integral_value() or q0 != q0.to_integral_value():
|
||||||
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
||||||
total = (q0 * wac).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
|
# Preserve the authoritative pre-action total independently. A per-unit WAC
|
||||||
after = (total / q1).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
|
# rounded to the storage scale cannot always multiply back to that total
|
||||||
if after * q1 != total:
|
# exactly, and that harmless decimal residue is not a fractional share.
|
||||||
raise CorporateActionError("fractional result unsupported", "FRACTIONAL_RESULT_UNSUPPORTED")
|
total = quantize_stored_decimal(q0 * wac)
|
||||||
return StockConsolidation(q0, q1, wac, after, total, ratio)
|
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]:
|
def apply_corporate_action_to_wac(quantity: Decimal, wac: Decimal, quantity_after: Decimal) -> tuple[Decimal, Decimal]:
|
||||||
result = calculate_stock_consolidation(quantity, quantity_after, wac)
|
result = calculate_stock_consolidation(quantity, quantity_after, wac)
|
||||||
|
|||||||
+10
-2
@@ -179,7 +179,11 @@ def calculate_realized_pnl(
|
|||||||
if abs(running_qty - before) > 1e-9:
|
if abs(running_qty - before) > 1e-9:
|
||||||
warning_triggered = True
|
warning_triggered = True
|
||||||
running_qty = after
|
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
|
warning: str | None = None
|
||||||
if warning_triggered:
|
if warning_triggered:
|
||||||
@@ -272,7 +276,11 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
|
|||||||
if abs(running_qty - before) > 1e-9:
|
if abs(running_qty - before) > 1e-9:
|
||||||
warning_triggered = True
|
warning_triggered = True
|
||||||
running_qty = after
|
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
|
warning: str | None = None
|
||||||
if warning_triggered:
|
if warning_triggered:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any
|
|||||||
import psycopg
|
import psycopg
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
|
|
||||||
|
from app.domain.corporate_actions import calculate_stock_consolidation
|
||||||
from app.domain.errors import (
|
from app.domain.errors import (
|
||||||
DaemonInactiveError,
|
DaemonInactiveError,
|
||||||
DaemonInUseError,
|
DaemonInUseError,
|
||||||
@@ -1668,11 +1669,14 @@ class PostgresPortfolioRepository:
|
|||||||
wac = calculate_current_wac(history)
|
wac = calculate_current_wac(history)
|
||||||
if Decimal(str(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before:
|
if Decimal(str(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before:
|
||||||
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
|
||||||
action.average_price_before = Decimal(str(wac.current_wac or 0))
|
consolidation = calculate_stock_consolidation(
|
||||||
action.total_cost_basis = (action.average_price_before * action.quantity_before).quantize(Decimal("0.00000001"))
|
action.quantity_before,
|
||||||
action.average_price_after = (action.total_cost_basis / action.quantity_after).quantize(Decimal("0.00000001"))
|
action.quantity_after,
|
||||||
if action.average_price_after * action.quantity_after != action.total_cost_basis:
|
Decimal(str(wac.current_wac or 0)),
|
||||||
raise ValueError("FRACTIONAL_RESULT_UNSUPPORTED")
|
)
|
||||||
|
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))
|
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()
|
prior = cur.fetchone()
|
||||||
if prior:
|
if prior:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# PortfolioHub v0.3.1 container contract
|
# PortfolioHub v0.3.2 container contract
|
||||||
|
|
||||||
## Publication is not deployment
|
## 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:<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:
|
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>
|
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
|
## 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_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
|
||||||
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
|
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
|
||||||
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name |
|
| `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
|
### 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 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.
|
- **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.
|
- **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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,7 @@
|
|||||||
- Auth: same owner-scoped user/session token policy as manual write.
|
- Auth: same owner-scoped user/session token policy as manual write.
|
||||||
- Query: `limit` 1..200, default 50.
|
- 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.
|
- `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.
|
- `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.
|
- `portfolio corporate-action` is the operator command; it requires an idempotency key and confirmation unless `--force` is supplied.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "portfoliohub"
|
name = "portfoliohub"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
description = "PortfolioHub API for portfolio management and ledger access"
|
description = "PortfolioHub API for portfolio management and ledger access"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -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")
|
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||||
if path.name != "__init__.py"
|
if path.name != "__init__.py"
|
||||||
)
|
)
|
||||||
assert 'version = "0.3.1"' in project
|
assert 'version = "0.3.2"' in project
|
||||||
assert 'app_version: str = "0.3.1"' in config
|
assert 'app_version: str = "0.3.2"' in config
|
||||||
assert len(migrations) == 31
|
assert len(migrations) == 31
|
||||||
assert migrations[-1].startswith("20260913_0031_")
|
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",
|
"Image rollback is **not** database rollback",
|
||||||
"read-only root filesystem",
|
"read-only root filesystem",
|
||||||
"10001:10001",
|
"10001:10001",
|
||||||
"PortfolioHub v0.3.1 container contract",
|
"PortfolioHub v0.3.2 container contract",
|
||||||
"v0.2.1 includes migration `0030`",
|
"v0.2.1 includes migration `0030`",
|
||||||
"trex-web v0.2.3 displays `strategy_nick`",
|
"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:
|
with pytest.raises(CorporateActionError) as exc:
|
||||||
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
|
calculate_stock_consolidation(Decimal(3), Decimal("1.5"), Decimal(10))
|
||||||
assert exc.value.code == "FRACTIONAL_RESULT_UNSUPPORTED"
|
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
|
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():
|
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")
|
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")
|
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 decimal import Decimal
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from uuid import UUID
|
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"):
|
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):
|
def _repo_for_row(asset_id):
|
||||||
@@ -47,8 +47,8 @@ def _repo_for_row(asset_id):
|
|||||||
conn.__enter__.return_value = conn
|
conn.__enter__.return_value = conn
|
||||||
conn.cursor.return_value.__enter__.return_value = cur
|
conn.cursor.return_value.__enter__.return_value = cur
|
||||||
repo._connect = MagicMock(return_value=conn)
|
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.fetchone.side_effect = [(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
|
||||||
cur.fetchall.return_value = []
|
cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))]
|
||||||
return repo
|
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"))
|
repo = _repo_for_row(UUID("12345678-1234-5678-1234-567812345678"))
|
||||||
with pytest.raises(ValueError, match="CORPORATE_ACTION_ASSET_MISMATCH"):
|
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")
|
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
|
||||||
|
|||||||
@@ -592,7 +592,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portfoliohub"
|
name = "portfoliohub"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|||||||
Reference in New Issue
Block a user