chore(release): prepare v0.3.1

This commit is contained in:
2026-09-13 15:32:27 +09:00
parent 5b443f718e
commit 1010d257ad
18 changed files with 176 additions and 42 deletions
+9 -1
View File
@@ -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
+9 -1
View File
@@ -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
+14 -5
View File
@@ -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:
+7 -3
View File
@@ -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)
+1 -1
View File
@@ -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")
@@ -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
+4 -2
View File
@@ -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"):
+3 -3
View File
@@ -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()
+2
View File
@@ -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")
+10 -10
View File
@@ -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:<source-commit-first-12>`
- `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.
+1 -1
View File
@@ -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"
+73
View File
@@ -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())
+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.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`",
):
+24
View File
@@ -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)) == []
+5 -2
View File
@@ -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"
+6 -6
View File
@@ -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)
+3 -3
View File
@@ -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
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]]
name = "portfoliohub"
version = "0.3.0"
version = "0.3.1"
source = { editable = "." }
dependencies = [
{ name = "alembic" },