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 sync --extra dev --locked
- 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 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 - 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
+9 -1
View File
@@ -22,7 +22,15 @@ jobs:
- run: uv sync --extra dev --locked - run: uv sync --extra dev --locked
- 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 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 - 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
+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.core.config import get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import CorporateAction, Portfolio, PortfolioComment 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.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.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.corporate_actions import (
CorporateActionRequest,
CorporateActionResponse,
)
from app.schemas.v1 import ( from app.schemas.v1 import (
CorporateActionLedgerHistoryItem,
DividendLedgerHistoryItem, DividendLedgerHistoryItem,
DividendManualLedgerResponse, DividendManualLedgerResponse,
HoldingsCostBasisResponse, HoldingsCostBasisResponse,
LedgerHistoryItem, LedgerHistoryItem,
CorporateActionLedgerHistoryItem,
LedgerHistoryResponse, LedgerHistoryResponse,
LedgerTradeRequest, LedgerTradeRequest,
LedgerTradeResponse, LedgerTradeResponse,
@@ -57,7 +61,6 @@ from app.schemas.v1 import (
TradeHistoryItem, TradeHistoryItem,
TradeHistoryResponse, TradeHistoryResponse,
) )
from app.schemas.corporate_actions import CorporateActionRequest, CorporateActionResponse
from app.services.strategy_set_service import ( from app.services.strategy_set_service import (
StrategyInactiveError, StrategyInactiveError,
StrategyNotFoundError, 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) @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(): if not idempotency_key or not idempotency_key.strip():
raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty") raise HTTPException(status_code=422, detail="Idempotency-Key must not be empty")
repo = _get_repo() 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 from app.domain.corporate_actions import canonical_payload_hash
digest = canonical_payload_hash(payload) digest = canonical_payload_hash(payload)
# The repository derives all WAC and immutable snapshot fields under its row lock. # 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) saved = repo.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
return CorporateActionResponse(**saved.__dict__) return CorporateActionResponse(**saved.__dict__)
except ValueError as exc: except ValueError as exc:
+7 -3
View File
@@ -288,10 +288,14 @@ def corporate_action_portfolio(
force: bool = typer.Option(False, "--force"), force: bool = typer.Option(False, "--force"),
) -> None: ) -> None:
"""Record an auditable stock consolidation without a trade.""" """Record an auditable stock consolidation without a trade."""
import uuid
from datetime import date from datetime import date
from decimal import Decimal 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 from app.domain.models import CorporateAction
if action_type != "stock_consolidation": if action_type != "stock_consolidation":
ctx.obj.console.print("Error: INVALID_CORPORATE_ACTION_TYPE") 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") 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} 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) 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) 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) return repository.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest)
detail = _with_service_and_repo(ctx, _callback) detail = _with_service_and_repo(ctx, _callback)
+1 -1
View File
@@ -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.0" app_version: str = "0.3.1"
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")
@@ -1,6 +1,7 @@
"""Additive storage for auditable, idempotent stock consolidations.""" """Additive storage for auditable, idempotent stock consolidations."""
from alembic import op from alembic import op
from sqlalchemy import text from sqlalchemy import text
revision = "20260913_0031" revision = "20260913_0031"
down_revision = "20260820_0030" down_revision = "20260820_0030"
branch_labels = None branch_labels = None
+4 -2
View File
@@ -1,9 +1,11 @@
"""Pure domain rules for portfolio stock consolidations.""" """Pure domain rules for portfolio stock consolidations."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
import hashlib import hashlib
import json import json
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
def canonical_payload_hash(payload: object) -> str: def canonical_payload_hash(payload: object) -> str:
if hasattr(payload, "model_dump"): if hasattr(payload, "model_dump"):
+3 -3
View File
@@ -7,7 +7,7 @@ import json
import logging import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, datetime from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any from typing import Any
@@ -22,10 +22,10 @@ from app.domain.errors import (
StrategyNotFoundError, StrategyNotFoundError,
) )
from app.domain.models import ( from app.domain.models import (
CorporateAction,
Asset, Asset,
AuthToken, AuthToken,
BrokerCredential, BrokerCredential,
CorporateAction,
Daemon, Daemon,
LedgerEntry, LedgerEntry,
Portfolio, Portfolio,
@@ -1631,7 +1631,7 @@ class PostgresPortfolioRepository:
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction: def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction:
"""Persist an immutable action and snapshot atomically.""" """Persist an immutable action and snapshot atomically."""
self.ensure_schema() 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: 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,)) 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() row = cur.fetchone()
+2
View File
@@ -1,8 +1,10 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
# Corporate actions (stock_consolidation-only, issue #34) # Corporate actions (stock_consolidation-only, issue #34)
class CorporateActionRequest(BaseModel): class CorporateActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid") 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 ## 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:<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: 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.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 ## 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.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 ### 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 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.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. 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.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. 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] [project]
name = "portfoliohub" name = "portfoliohub"
version = "0.3.0" version = "0.3.1"
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"
+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") for path in (REPO / "app/db/alembic/versions").glob("*.py")
if path.name != "__init__.py" if path.name != "__init__.py"
) )
assert 'version = "0.3.0"' in project assert 'version = "0.3.1"' in project
assert 'app_version: str = "0.3.0"' in config assert 'app_version: str = "0.3.1"' 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.0 container contract", "PortfolioHub v0.3.1 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`",
): ):
+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 decimal import Decimal
from app.domain.corporate_actions import calculate_stock_consolidation from app.domain.corporate_actions import calculate_stock_consolidation
def test_stock_consolidation_preserves_cost_and_scales_wac(): 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.average_price_after == Decimal("2172.46")
assert result.total_cost_basis == Decimal("1577205.96000000") assert result.total_cost_basis == Decimal("1577205.96000000")
assert result.ratio == Decimal("0.5") assert result.ratio == Decimal("0.5")
def test_stock_consolidation_rejects_fractional_result(): def test_stock_consolidation_rejects_fractional_result():
import pytest import pytest
from app.domain.corporate_actions import CorporateActionError from app.domain.corporate_actions import CorporateActionError
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"
+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 decimal import Decimal
from pathlib import Path 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(): def test_buy_consolidation_sell_replays_wac_and_realized_pnl():
rows = [ rows = [
{"side": "buy", "quantity": 10, "price": 100, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 1, 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=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=UTC)},
{"side": "sell", "quantity": 2, "price": 250, "fee_amount": 0, "tax_amount": 0, "executed_at": datetime(2026, 1, 3, tzinfo=timezone.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 assert calculate_current_wac(rows).current_wac == 200
result = calculate_realized_pnl(rows) 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(): 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")
assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy()) assert canonical_payload_hash(a) == canonical_payload_hash(a.model_copy())
assert canonical_payload_hash(a) != canonical_payload_hash(b) 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 fastapi.testclient import TestClient
from app.api import v1_portfolios 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.main import create_app
from app.repositories.postgres import PostgresPortfolioRepository 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"): 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): def _repo_for_row(asset_id):
@@ -47,7 +47,7 @@ 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(0), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
cur.fetchall.return_value = [] cur.fetchall.return_value = []
return repo return repo
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]] [[package]]
name = "portfoliohub" name = "portfoliohub"
version = "0.3.0" version = "0.3.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },