Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4520fbbfbd | ||
|
|
1010d257ad |
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
|
||||
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}" --project pyproject.toml
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -41,7 +41,11 @@ 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 Ruff findings introduced by v0.3.0 or its patch
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
- 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
|
||||
|
||||
@@ -15,6 +15,8 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -22,7 +24,11 @@ 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 Ruff findings introduced by v0.3.0 or its patch
|
||||
run: |
|
||||
uv run python scripts/ruff_ratchet.py --base v0.2.1
|
||||
uv run python scripts/ruff_ratchet.py --base v0.3.0
|
||||
- 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reject Ruff findings introduced since a stable release tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _run(command: list[str], *, cwd: Path, accepted: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
|
||||
if result.returncode not in accepted:
|
||||
raise RuntimeError(
|
||||
f"command failed ({result.returncode}): {' '.join(command)}\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def changed_python_paths(repo: Path, base: str) -> list[str]:
|
||||
result = _run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--diff-filter=ACMR",
|
||||
f"{base}...HEAD",
|
||||
"--",
|
||||
"*.py",
|
||||
],
|
||||
cwd=repo,
|
||||
)
|
||||
return sorted(path for path in result.stdout.split("\0") if path)
|
||||
|
||||
|
||||
def _source_line(root: Path, relative_path: str, row: int) -> str:
|
||||
lines = (root / relative_path).read_text(encoding="utf-8").splitlines()
|
||||
return lines[row - 1].strip() if 0 < row <= len(lines) else ""
|
||||
|
||||
|
||||
def normalized_findings(
|
||||
diagnostics: list[dict[str, object]], root: Path
|
||||
) -> Counter[tuple[str, str, str, str]]:
|
||||
findings: Counter[tuple[str, str, str, str]] = Counter()
|
||||
for diagnostic in diagnostics:
|
||||
filename = Path(str(diagnostic["filename"])).resolve()
|
||||
relative_path = filename.relative_to(root.resolve()).as_posix()
|
||||
location = diagnostic["location"]
|
||||
if not isinstance(location, dict):
|
||||
raise TypeError("Ruff diagnostic location must be an object")
|
||||
row = int(location["row"])
|
||||
finding = (
|
||||
relative_path,
|
||||
str(diagnostic["code"]),
|
||||
str(diagnostic["message"]),
|
||||
_source_line(root, relative_path, row),
|
||||
)
|
||||
findings[finding] += 1
|
||||
return findings
|
||||
|
||||
|
||||
def _ruff_diagnostics(root: Path, paths: list[str]) -> list[dict[str, object]]:
|
||||
if not paths:
|
||||
return []
|
||||
ruff = shutil.which("ruff")
|
||||
if ruff is None:
|
||||
raise RuntimeError("ruff executable is not available")
|
||||
result = _run(
|
||||
[ruff, "check", *paths, "--output-format=json"],
|
||||
cwd=root,
|
||||
accepted=(0, 1),
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
if not isinstance(payload, list):
|
||||
raise TypeError("Ruff JSON output must be a list")
|
||||
return payload
|
||||
|
||||
|
||||
def introduced_findings(repo: Path, base: str) -> tuple[list[str], Counter[tuple[str, str, str, str]]]:
|
||||
paths = changed_python_paths(repo, base)
|
||||
if not paths:
|
||||
return paths, Counter()
|
||||
|
||||
current = normalized_findings(_ruff_diagnostics(repo, paths), repo)
|
||||
with tempfile.TemporaryDirectory(prefix="portfoliohub-ruff-base-") as parent:
|
||||
baseline_root = Path(parent) / "tree"
|
||||
_run(["git", "worktree", "add", "--detach", str(baseline_root), base], cwd=repo)
|
||||
try:
|
||||
baseline_paths = [path for path in paths if (baseline_root / path).is_file()]
|
||||
baseline = normalized_findings(
|
||||
_ruff_diagnostics(baseline_root, baseline_paths), baseline_root
|
||||
)
|
||||
finally:
|
||||
_run(
|
||||
["git", "worktree", "remove", "--force", str(baseline_root)],
|
||||
cwd=repo,
|
||||
)
|
||||
return paths, current - baseline
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True, help="stable comparison tag")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(
|
||||
_run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd()).stdout.strip()
|
||||
)
|
||||
ruff_version = _run([shutil.which("ruff") or "ruff", "--version"], cwd=repo).stdout.strip()
|
||||
paths, introduced = introduced_findings(repo, args.base)
|
||||
print(f"base={args.base} head=HEAD selector=ACMR-python files={len(paths)} tool={ruff_version}")
|
||||
for path in paths:
|
||||
print(f"selected={path}")
|
||||
if introduced:
|
||||
for (path, code, message, source), count in sorted(introduced.items()):
|
||||
print(f"NEW {count}x {path}: {code} {message} | {source}")
|
||||
print(f"introduced_findings={sum(introduced.values())}")
|
||||
return 1
|
||||
print("introduced_findings=0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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`",
|
||||
):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from collections import Counter
|
||||
|
||||
from scripts.ruff_ratchet import normalized_findings
|
||||
|
||||
|
||||
def test_normalized_findings_ignore_line_number_changes(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("\nvalue = missing\n", encoding="utf-8")
|
||||
diagnostics = [
|
||||
{
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 2, "column": 9},
|
||||
}
|
||||
]
|
||||
|
||||
assert normalized_findings(diagnostics, tmp_path) == Counter(
|
||||
{("sample.py", "F821", "Undefined name `missing`", "value = missing"): 1}
|
||||
)
|
||||
|
||||
|
||||
def test_normalized_findings_preserve_duplicate_count(tmp_path):
|
||||
source = tmp_path / "sample.py"
|
||||
source.write_text("missing\n", encoding="utf-8")
|
||||
diagnostic = {
|
||||
"filename": str(source),
|
||||
"code": "F821",
|
||||
"message": "Undefined name `missing`",
|
||||
"location": {"row": 1, "column": 1},
|
||||
}
|
||||
|
||||
assert normalized_findings([diagnostic, diagnostic], tmp_path).total() == 2
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user