diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index a9c1728..d68e600 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -41,7 +41,15 @@ jobs: - run: uv sync --extra dev --locked - 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 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 increases over the v0.2.1 Ruff debt baseline + run: | + set +e + uv run ruff check . --output-format=json > ruff-diagnostics.json + ruff_status=$? + set -e + test "$ruff_status" -eq 0 -o "$ruff_status" -eq 1 + uv run python scripts/ruff_ratchet.py ruff-diagnostics.json - 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 6c1ff3a..57fb86f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -22,7 +22,15 @@ jobs: - run: uv sync --extra dev --locked - 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 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 increases over the v0.2.1 Ruff debt baseline + run: | + set +e + uv run ruff check . --output-format=json > ruff-diagnostics.json + ruff_status=$? + set -e + test "$ruff_status" -eq 0 -o "$ruff_status" -eq 1 + uv run python scripts/ruff_ratchet.py ruff-diagnostics.json - 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/api/v1_portfolios.py b/app/api/v1_portfolios.py index e59318e..b91677c 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -24,15 +24,19 @@ from app.core.auth import AuthContext, Permission, require_read, require_trade_w from app.core.config import get_settings from app.domain.errors import DaemonInactiveError, DaemonNotFoundError from app.domain.models import CorporateAction, Portfolio, PortfolioComment -from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE from app.domain.pnl import calculate_current_wac, calculate_realized_pnl +from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE from app.repositories.factory import get_portfolio_repository_from_settings +from app.schemas.corporate_actions import ( + CorporateActionRequest, + CorporateActionResponse, +) from app.schemas.v1 import ( + CorporateActionLedgerHistoryItem, DividendLedgerHistoryItem, DividendManualLedgerResponse, HoldingsCostBasisResponse, LedgerHistoryItem, - CorporateActionLedgerHistoryItem, LedgerHistoryResponse, LedgerTradeRequest, LedgerTradeResponse, @@ -57,7 +61,6 @@ from app.schemas.v1 import ( TradeHistoryItem, TradeHistoryResponse, ) -from app.schemas.corporate_actions import CorporateActionRequest, CorporateActionResponse from app.services.strategy_set_service import ( StrategyInactiveError, StrategyNotFoundError, @@ -1313,7 +1316,13 @@ def get_holdings_cost_basis( @router.post("/{portfolio_id}/corporate-actions", response_model=CorporateActionResponse, status_code=status.HTTP_201_CREATED) -def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest, response: Response, auth: AuthContext = Depends(require_trade_write), idempotency_key: str = Header(..., alias="Idempotency-Key")): +def create_corporate_action( + portfolio_id: str, + payload: CorporateActionRequest, + response: Response, + auth: AuthContext = Depends(require_trade_write), # noqa: B008 - FastAPI dependency declaration + idempotency_key: str = Header(..., alias="Idempotency-Key"), +): if not idempotency_key or not idempotency_key.strip(): raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty") repo = _get_repo() @@ -1328,7 +1337,7 @@ def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest, from app.domain.corporate_actions import canonical_payload_hash digest = canonical_payload_hash(payload) # The repository derives all WAC and immutable snapshot fields under its row lock. - action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal("0"), Decimal("0"), Decimal("0"), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id) + action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, payload.action_type, payload.effective_date, payload.quantity_before, payload.quantity_after, Decimal(0), Decimal(0), Decimal(0), Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), payload.evidence_ref, idempotency_key, digest, 0, auth.user_id) saved = repo.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest) return CorporateActionResponse(**saved.__dict__) except ValueError as exc: diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index 0fd227c..413aed3 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -288,10 +288,14 @@ def corporate_action_portfolio( force: bool = typer.Option(False, "--force"), ) -> None: """Record an auditable stock consolidation without a trade.""" + import uuid from datetime import date from decimal import Decimal - import uuid - from app.domain.corporate_actions import calculate_stock_consolidation, canonical_payload_hash + + from app.domain.corporate_actions import ( + calculate_stock_consolidation, + canonical_payload_hash, + ) from app.domain.models import CorporateAction if action_type != "stock_consolidation": ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE") @@ -312,7 +316,7 @@ def corporate_action_portfolio( raise ValueError("Idempotency-Key must not be empty") payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False} digest = canonical_payload_hash(payload) - calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal("0")) + calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal(0)) action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user) return repository.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest) detail = _with_service_and_repo(ctx, _callback) diff --git a/app/core/config.py b/app/core/config.py index 6cc780c..c07a9ed 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.0" + app_version: str = "0.3.1" 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/db/alembic/versions/20260913_0031_corporate_actions.py b/app/db/alembic/versions/20260913_0031_corporate_actions.py index 538d3ba..8d4dd2a 100644 --- a/app/db/alembic/versions/20260913_0031_corporate_actions.py +++ b/app/db/alembic/versions/20260913_0031_corporate_actions.py @@ -1,6 +1,7 @@ """Additive storage for auditable, idempotent stock consolidations.""" from alembic import op from sqlalchemy import text + revision = "20260913_0031" down_revision = "20260820_0030" branch_labels = None diff --git a/app/domain/corporate_actions.py b/app/domain/corporate_actions.py index 82b4302..a128380 100644 --- a/app/domain/corporate_actions.py +++ b/app/domain/corporate_actions.py @@ -1,9 +1,11 @@ """Pure domain rules for portfolio stock consolidations.""" from __future__ import annotations -from dataclasses import dataclass -from decimal import Decimal, ROUND_HALF_UP + import hashlib import json +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal + def canonical_payload_hash(payload: object) -> str: if hasattr(payload, "model_dump"): diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index c2af3ba..edca2e8 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -7,7 +7,7 @@ import json import logging import uuid from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import UTC, datetime from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from typing import Any @@ -22,10 +22,10 @@ from app.domain.errors import ( StrategyNotFoundError, ) from app.domain.models import ( - CorporateAction, Asset, AuthToken, BrokerCredential, + CorporateAction, Daemon, LedgerEntry, Portfolio, @@ -1631,7 +1631,7 @@ class PostgresPortfolioRepository: def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction: """Persist an immutable action and snapshot atomically.""" self.ensure_schema() - with self._connect() as conn: + with self._connect() as conn: # noqa: SIM117 - keep transaction and cursor lifetimes explicit with conn.cursor() as cur: cur.execute("SELECT asset_quantity, cash_balance, profit_buffer, asset_id, status FROM portfolios WHERE portfolio_id=%s FOR UPDATE", (portfolio_id,)) row = cur.fetchone() diff --git a/app/schemas/corporate_actions.py b/app/schemas/corporate_actions.py index ff48237..1bad6ca 100644 --- a/app/schemas/corporate_actions.py +++ b/app/schemas/corporate_actions.py @@ -1,8 +1,10 @@ from datetime import date, datetime from decimal import Decimal from typing import Literal + from pydantic import BaseModel, ConfigDict, Field, model_validator + # Corporate actions (stock_consolidation-only, issue #34) class CorporateActionRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/docs/container-operations.md b/docs/container-operations.md index 21b97a5..fa06cdb 100644 --- a/docs/container-operations.md +++ b/docs/container-operations.md @@ -1,11 +1,11 @@ -# PortfolioHub v0.3.0 container contract +# PortfolioHub v0.3.1 container contract ## Publication is not deployment -Only a requester-authorized push of the strict tag `v0.3.0` 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.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: - `ghcr.io/fourblocks2020/portfolio-hub:` -- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.0` +- `ghcr.io/fourblocks2020/portfolio-hub:v0.3.1` 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.0`. 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.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. -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.0 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.1 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.0 | `0.3.0` | no | health/OpenAPI version metadata | +| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.3.1 | `0.3.1` | 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.0 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.1 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.0 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.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. ## 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.0`. +- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.3.1`. 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.0 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. +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 repository-wide Ruff debt ratchet rejects any total or per-rule increase above the frozen v0.2.1 baseline. 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/pyproject.toml b/pyproject.toml index f67dcd2..909c708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "portfoliohub" -version = "0.3.0" +version = "0.3.1" description = "PortfolioHub API for portfolio management and ledger access" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/ruff_ratchet.py b/scripts/ruff_ratchet.py new file mode 100644 index 0000000..75e4c55 --- /dev/null +++ b/scripts/ruff_ratchet.py @@ -0,0 +1,73 @@ +"""Enforce the repository's v0.2.1 Ruff debt as a decreasing baseline.""" + +from __future__ import annotations + +import argparse +import json +from collections import Counter +from pathlib import Path +from typing import Any + +BASELINE_VERSION = "v0.2.1" +BASELINE_TOTAL = 576 +BASELINE_BY_RULE = { + "B008": 117, + "B017": 1, + "B018": 1, + "BLE001": 19, + "C408": 7, + "DTZ901": 1, + "F401": 65, + "F541": 6, + "F811": 16, + "F841": 20, + "FURB157": 2, + "I001": 90, + "RET501": 2, + "RUF015": 2, + "RUF059": 8, + "RUF100": 1, + "SIM102": 1, + "SIM103": 1, + "SIM114": 2, + "SIM115": 8, + "SIM117": 13, + "TRY004": 1, + "TRY203": 9, + "UP017": 166, + "UP035": 5, + "UP037": 4, + "UP045": 8, +} + + +def baseline_regressions(diagnostics: list[dict[str, Any]]) -> list[str]: + """Return rule-count or total-count increases above the frozen baseline.""" + counts = Counter(item["code"] for item in diagnostics) + regressions = [ + f"{code}: {count} > {BASELINE_BY_RULE.get(code, 0)}" + for code, count in sorted(counts.items()) + if count > BASELINE_BY_RULE.get(code, 0) + ] + if len(diagnostics) > BASELINE_TOTAL: + regressions.append(f"total: {len(diagnostics)} > {BASELINE_TOTAL}") + return regressions + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("diagnostics", type=Path) + args = parser.parse_args() + diagnostics = json.loads(args.diagnostics.read_text(encoding="utf-8")) + regressions = baseline_regressions(diagnostics) + if regressions: + print(f"Ruff debt increased above {BASELINE_VERSION}:") + for regression in regressions: + print(f"- {regression}") + return 1 + print(f"Ruff debt ratchet passed: {len(diagnostics)} <= {BASELINE_TOTAL} ({BASELINE_VERSION})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index aeeea22..3ec4942 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.0"' in project - assert 'app_version: str = "0.3.0"' in config + assert 'version = "0.3.1"' in project + assert 'app_version: str = "0.3.1"' 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.0 container contract", + "PortfolioHub v0.3.1 container contract", "v0.2.1 includes migration `0030`", "trex-web v0.2.3 displays `strategy_nick`", ): diff --git a/tests/release/test_ruff_ratchet.py b/tests/release/test_ruff_ratchet.py new file mode 100644 index 0000000..613c8a9 --- /dev/null +++ b/tests/release/test_ruff_ratchet.py @@ -0,0 +1,24 @@ +from scripts.ruff_ratchet import BASELINE_BY_RULE, BASELINE_TOTAL, baseline_regressions + + +def _diagnostics(**counts: int) -> list[dict[str, str]]: + return [{"code": code} for code, count in counts.items() for _ in range(count)] + + +def test_exact_baseline_passes() -> None: + diagnostics = _diagnostics(**BASELINE_BY_RULE) + assert len(diagnostics) == BASELINE_TOTAL + assert baseline_regressions(diagnostics) == [] + + +def test_new_rule_fails_from_zero_baseline() -> None: + assert baseline_regressions([{"code": "E501"}]) == ["E501: 1 > 0"] + + +def test_existing_rule_increase_fails() -> None: + diagnostics = _diagnostics(I001=BASELINE_BY_RULE["I001"] + 1) + assert baseline_regressions(diagnostics) == ["I001: 91 > 90"] + + +def test_reduced_debt_passes() -> None: + assert baseline_regressions(_diagnostics(I001=1, F401=1)) == [] diff --git a/tests/test_corporate_actions.py b/tests/test_corporate_actions.py index e866151..3a28cc0 100644 --- a/tests/test_corporate_actions.py +++ b/tests/test_corporate_actions.py @@ -1,15 +1,18 @@ from decimal import Decimal + from app.domain.corporate_actions import calculate_stock_consolidation + def test_stock_consolidation_preserves_cost_and_scales_wac(): - result = calculate_stock_consolidation(Decimal("1452"), Decimal("726"), Decimal("1086.23")) + result = calculate_stock_consolidation(Decimal(1452), Decimal(726), Decimal("1086.23")) assert result.average_price_after == Decimal("2172.46") assert result.total_cost_basis == Decimal("1577205.96000000") assert result.ratio == Decimal("0.5") def test_stock_consolidation_rejects_fractional_result(): import pytest + from app.domain.corporate_actions import CorporateActionError 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" diff --git a/tests/test_issue34_regressions.py b/tests/test_issue34_regressions.py index 2253187..2a62330 100644 --- a/tests/test_issue34_regressions.py +++ b/tests/test_issue34_regressions.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from decimal import Decimal from pathlib import Path @@ -9,9 +9,9 @@ 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=timezone.utc)}, - {"side": "stock_consolidation", "quantity": 0, "price": 0, "quantity_before": 10, "quantity_after": 5, "average_price_after": 200, "executed_at": datetime(2026, 1, 2, tzinfo=timezone.utc)}, - {"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=timezone.utc)}, + {"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, "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) @@ -20,8 +20,8 @@ def test_buy_consolidation_sell_replays_wac_and_realized_pnl(): 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") + 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) diff --git a/tests/test_issue34_runtime_blockers.py b/tests/test_issue34_runtime_blockers.py index 16987ca..b129cad 100644 --- a/tests/test_issue34_runtime_blockers.py +++ b/tests/test_issue34_runtime_blockers.py @@ -7,7 +7,7 @@ import pytest from fastapi.testclient import TestClient from app.api import v1_portfolios -from app.domain.models import CorporateAction, Portfolio +from app.domain.models import CorporateAction from app.main import create_app from app.repositories.postgres import PostgresPortfolioRepository @@ -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(0), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0) def _repo_for_row(asset_id): @@ -47,7 +47,7 @@ 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.fetchone.side_effect = [(Decimal(0), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))] cur.fetchall.return_value = [] return repo diff --git a/uv.lock b/uv.lock index 1770312..1faf026 100644 --- a/uv.lock +++ b/uv.lock @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "portfoliohub" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "alembic" },