From 5d6a0ad013900668930fe947f342e1ad1824edaa Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 22 Aug 2026 21:21:24 +0900 Subject: [PATCH] feat(portfolios): allow unassigned strategy creation --- .proofline/criteria/ac-0016.md | 2 +- .proofline/criteria/ac-0017.md | 10 +++++----- .proofline/criteria/ac-0021.md | 8 ++++---- .proofline/criteria/ac-0024.md | 4 ++-- app/api/v1_portfolios.py | 16 +++++++++------- app/core/config.py | 2 +- app/schemas/v1.py | 2 +- docs/container-operations.md | 24 ++++++++++++------------ docs/interface-contract.md | 6 +++--- pyproject.toml | 2 +- tests/release/test_release_contracts.py | 10 +++++----- tests/test_v1_portfolios_crud.py | 7 ++++--- uv.lock | 2 +- 13 files changed, 49 insertions(+), 46 deletions(-) diff --git a/.proofline/criteria/ac-0016.md b/.proofline/criteria/ac-0016.md index fd14763..c77f655 100644 --- a/.proofline/criteria/ac-0016.md +++ b/.proofline/criteria/ac-0016.md @@ -7,7 +7,7 @@ status: active ## Criterion -신규 포트폴리오 생성·수정은 전역 카탈로그의 기존 active `strategy_id`만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다. +신규 포트폴리오 생성·수정에서 `strategy_id`를 지정하는 경우 전역 카탈로그의 기존 active ID만 선택할 수 있어야 하며, 전략을 inactive로 전환해도 기존 포트폴리오 참조는 보존되고 신규 선택만 차단되어야 한다. 전략 미지정 또는 `null`은 선택이 아니며 전략 참조를 만들지 않는다. ## Verification diff --git a/.proofline/criteria/ac-0017.md b/.proofline/criteria/ac-0017.md index 77db79b..7235c0e 100644 --- a/.proofline/criteria/ac-0017.md +++ b/.proofline/criteria/ac-0017.md @@ -7,11 +7,11 @@ status: active ## Criterion -`/v1/portfolios`와 legacy `/portfolios`의 생성에는 active `strategy_id`가 필수이고 수정도 ID-only여야 하며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 `strategy_nick`은 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다. +`/v1/portfolios`와 legacy `/portfolios`의 생성은 `strategy_id`를 생략하거나 `null`로 전달해도 가능해야 하며, 이 경우 전략 참조를 만들거나 기본 전략을 암묵적으로 선택해서는 안 된다. `strategy_id`가 제공된 생성·수정은 ID-only여야 하고 기존 active 전략 ID만 선택할 수 있으며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 이 전략 메타데이터는 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다. ## Verification -- 성공 조건: 명시한 active ID로만 포트폴리오가 생성·수정되고 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타나며 참조가 없으면 `strategy_nick`은 `null`이다. -- 실패·경계 조건: strategy ID 누락, 제거 필드 전달 또는 malformed 요청은 새 전략 row를 만들지 않고 `422`다. -- 검사 방법: v1·legacy create/update schema와 저장소 row count, v1 create/PATCH/list의 `strategy_nick` read-back, unbound `null` 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다. -- 현재 구현 평가: schemas와 양쪽 portfolio API가 ID-only 입력 및 `extra="forbid"` 계약을 구현하고 v1 mapper가 `strategy_nick`을 반환한다. 다만 `strategy_nick` read-back, 제거된 `strategy_name`·`strategy_params` 각각의 `422`와 응답 호환 필드 전체를 직접 단언하는 전용 test가 없어 executable coverage는 `PARTIAL`이다. +- 성공 조건: strategy ID 없이 또는 `null`로 포트폴리오가 생성되고 전략 참조와 전략 메타데이터가 모두 `null`이며, 기본 전략이 자동 연결되지 않는다. 명시한 active ID로 생성·수정하면 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타난다. +- 실패·경계 조건: 제공된 unknown ID는 `404`, inactive ID는 `409`이며, 제거 필드·빈 문자열·malformed 요청은 새 전략 row를 만들지 않고 `422`다. +- 검사 방법: v1·legacy create/update schema와 저장소 row count, strategy 미지정·explicit `null`·active/unknown/inactive ID의 create read-back, v1 create/PATCH/list 전략 메타데이터 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다. +- 현재 구현 평가: v1 schema와 API는 strategy 미지정·`null` 생성을 구현하고 회귀 테스트가 이를 검사한다. legacy `/portfolios` schema와 생성 경로는 아직 `strategy_id`를 필수로 유지하므로 전체 계약의 executable coverage는 `PARTIAL`이며, legacy 동등 구현과 read-back 회귀가 필요하다. diff --git a/.proofline/criteria/ac-0021.md b/.proofline/criteria/ac-0021.md index c384abe..e7ab178 100644 --- a/.proofline/criteria/ac-0021.md +++ b/.proofline/criteria/ac-0021.md @@ -7,11 +7,11 @@ status: active ## Criterion -Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고, 동일 `(name, config)` row가 있으면 재사용해야 하며 포트폴리오 요청의 `strategy_id` 필수 조건을 완화하지 않아야 한다. +Alembic data migration은 active `name="target_value"`, `config={}` 전략을 중복 없이 보장하고 동일 `(name, config)` row가 있으면 재사용해야 한다. 기본 전략의 존재는 strategy 미지정 또는 `null` 포트폴리오 생성에서 암묵적 전략 선택을 유발해서는 안 된다. ## Verification - 성공 조건: 빈 DB upgrade 후 기본 전략 한 건이 active, revision 1과 runtime 일치 derived identity로 존재한다. -- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 허용하지 않는다. -- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, portfolio create negative test를 수행한다. -- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, 2026-08-15 실제 PostgreSQL integration `4 passed`에서 신규 DB full upgrade와 head 재실행을 재검증했다. +- 실패·경계 조건: migration 재실행이나 기존 동일 row가 중복 row를 만들지 않고, 기본 전략 존재가 implicit portfolio selection을 유발하지 않는다. +- 검사 방법: migration unit/idempotency test와 신규 PostgreSQL DB full upgrade/head 재실행, strategy 미지정·explicit `null` portfolio create read-back을 수행한다. +- 현재 구현 평가: migration unit test가 scoped 재실행에서 PASS했고, v1 route에는 strategy 미지정 생성 회귀가 있다. legacy `/portfolios`에도 동일한 생성·read-back 검증이 추가되기 전까지 executable coverage는 `PARTIAL`이다. diff --git a/.proofline/criteria/ac-0024.md b/.proofline/criteria/ac-0024.md index 014bf9a..f9f0de3 100644 --- a/.proofline/criteria/ac-0024.md +++ b/.proofline/criteria/ac-0024.md @@ -7,11 +7,11 @@ status: active ## Criterion -유효한 `strategy_id`와 portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다. +strategy ID의 지정 여부와 무관하게, portfolio owner가 소유한 active daemon의 server-generated UUID를 포함해 포트폴리오를 생성하면 HTTP 201 응답과 후속 portfolio 조회의 `managed_by_daemon_id`가 요청한 daemon ID와 같아야 한다. strategy ID를 생략하거나 `null`로 전달한 경우 전략 참조는 만들지 않는다. ## Verification -- 성공 조건: POST 응답과 저장소 조회 및 API 후속 조회가 모두 같은 non-null daemon ID를 반환한다. +- 성공 조건: strategy ID를 지정한 경우와 생략하거나 `null`로 전달한 경우 모두 POST 응답·저장소 조회·API 후속 조회가 같은 non-null daemon ID를 반환하며, strategy 미지정 생성의 `active_strategy_id`는 `null`이다. - 실패·경계 조건: persistence 경계에서 daemon ID가 누락되거나 `null`로 바뀌면 실패한다. - 검사 방법: API test와 migration head가 적용된 disposable PostgreSQL에서 `POST → GET` read-back을 실행한다. - 현재 구현 평가: `e2102b6`의 생성 handler·repository INSERT 경로와 PostgreSQL integration test가 대응하며 2차 QC에서 관련 suite가 통과했다. diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 6850c3e..6f3ddbe 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -186,11 +186,13 @@ def create_portfolio_v1( ) name = body.name or f"{body.broker}-{body.asset_id[:8]}" - strategy_set = repo.get_strategy_set(body.strategy_id) - if strategy_set is None: - raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}") - if strategy_set.status != "active": - raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}") + strategy_set = None + if body.strategy_id is not None: + strategy_set = repo.get_strategy_set(body.strategy_id) + if strategy_set is None: + raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}") + if strategy_set.status != "active": + raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}") _require_valid_daemon_uuid(body.managed_by_daemon_id) @@ -205,8 +207,8 @@ def create_portfolio_v1( asset_quantity=body.asset_quantity, asset_price=body.asset_price, profit_buffer=body.profit_buffer, - active_strategy_id=strategy_set.id, - avabile_strategies=[strategy_set.id], + active_strategy_id=strategy_set.id if strategy_set is not None else None, + avabile_strategies=[strategy_set.id] if strategy_set is not None else None, managed_by_daemon_id=body.managed_by_daemon_id, ) except HTTPException: diff --git a/app/core/config.py b/app/core/config.py index 83f517d..8ec9594 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.2.0" + app_version: str = "0.2.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/schemas/v1.py b/app/schemas/v1.py index 048e48f..eaf8a6b 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -283,7 +283,7 @@ class PortfolioCreateRequest(BaseModel): asset_quantity: float = Field(default=0.0, ge=0.0) asset_price: float = Field(default=0.0, ge=0.0) profit_buffer: float = Field(default=0.0, ge=0.0) - strategy_id: str = Field(..., min_length=1) + strategy_id: str | None = Field(default=None, min_length=1) managed_by_daemon_id: str | None = None user_id: str | None = None # admin-only: override owner diff --git a/docs/container-operations.md b/docs/container-operations.md index 48672fe..1750e5c 100644 --- a/docs/container-operations.md +++ b/docs/container-operations.md @@ -1,11 +1,11 @@ -# PortfolioHub v0.2.0 container contract +# PortfolioHub v0.2.1 container contract ## Publication is not deployment -Only a requester-authorized push of the strict tag `v0.2.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.2.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.2.0` +- `ghcr.io/fourblocks2020/portfolio-hub:v0.2.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.2.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.2.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.2.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.2.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.2.0 | `0.2.0` | no | health/OpenAPI version metadata | +| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.2.1 | `0.2.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.2.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.2.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.2.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.2.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.2.0`. +- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.2.1`. Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations. @@ -97,7 +97,7 @@ The installed image contains exactly **30** migration files and one Alembic head Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes. -v0.2.0 adds migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows. +v0.2.1 includes migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows. A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades through `0030`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation. @@ -105,13 +105,13 @@ Image rollback is **not** database rollback. Selecting older application bytes d ## API verification and known caller blockers -v0.2.0 preserves Bearer authentication and adds all-source unified-ledger reads. 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 30-file migration inventory, isolated PostgreSQL migration twice, readiness at `0030`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. +v0.2.1 preserves Bearer authentication and adds all-source unified-ledger reads. 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 30-file migration inventory, isolated PostgreSQL migration twice, readiness at `0030`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it. 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. Route presence is **not** a positive cross-repository caller-compatibility claim. Direct source comparison found the following unresolved consumers, which the requester explicitly separated as BLOCKED follow-up rather than adding legacy shims to portfolio-hub: -- trex-web omits required `strategy_id` when creating a portfolio, so that mutation currently returns `422`; +- trex-web may create a portfolio without `strategy_id`; portfolio-hub must preserve that strategy-unassigned request as `active_strategy_id=null` rather than selecting a default strategy implicitly; - trex-web can omit required `account_ref` when creating or rotating credentials, so those requests currently return `422`; - trex-web sends daemon activation state through the general PATCH route, which can return `200` without changing activation; it must use the activate/deactivate routes; - portfolio-hub returns singular `broker` from `/v1/user_info`, while trex-web types `brokers[]`; diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 1889b3a..cab856f 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -302,11 +302,11 @@ ### Endpoint: `POST /v1/portfolios` - Auth: any valid token; service/client → HTTP 403 -- Body: `{"asset_id": "", "broker": "", "name": "", "cash_balance": , "asset_quantity": , "asset_price": , "profit_buffer": , "strategy_id": "", "managed_by_daemon_id": "", "user_id": " (admin only)"}` -- Response: HTTP 201 `PortfolioItem {"portfolio_id": "", ..., "active_strategy_id": "", "strategy_name": "", "strategy_params": {}, "strategy_revision": , ...}` +- Body: `{"asset_id": "", "broker": "", "name": "", "cash_balance": , "asset_quantity": , "asset_price": , "profit_buffer": , "strategy_id": "", "managed_by_daemon_id": "", "user_id": " (admin only)"}` +- Response: HTTP 201 `PortfolioItem {"portfolio_id": "", ..., "active_strategy_id": "", "strategy_name": "", "strategy_params": {}, "strategy_revision": , ...}` - Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction. - Note: a non-null `managed_by_daemon_id` must be a daemon server-generated UUID that exists, is active, and is owned by the target portfolio owner (admin `user_id` override uses the final target owner, not the calling admin). Daemon validation and the portfolio write share one atomic transaction (`FOR SHARE` daemon lock); a selected daemon that is deactivated concurrently is rejected rather than committed. -- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if the strategy is inactive or the daemon is inactive; HTTP 422 for a missing/invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id` +- Error: HTTP 403 if service/client token; HTTP 404 if a supplied `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if a supplied strategy is inactive or the daemon is inactive; HTTP 422 for an invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id` ### Endpoint: `PATCH /v1/portfolios/{portfolio_id}` diff --git a/pyproject.toml b/pyproject.toml index 577c6d2..9e7a9f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "portfoliohub" -version = "0.2.0" +version = "0.2.1" description = "PortfolioHub API for portfolio management and ledger access" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 9dbfed6..e062a9b 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -50,7 +50,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive assert 'RELEASE_VERSION=v0.1.3' not in verify -def test_release_version_and_migration_contract_are_v020_head_0030(): +def test_release_version_and_migration_contract_are_v021_head_0030(): project = (REPO / "pyproject.toml").read_text() config = (REPO / "app/core/config.py").read_text() migrations = sorted( @@ -58,8 +58,8 @@ def test_release_version_and_migration_contract_are_v020_head_0030(): for path in (REPO / "app/db/alembic/versions").glob("*.py") if path.name != "__init__.py" ) - assert 'version = "0.2.0"' in project - assert 'app_version: str = "0.2.0"' in config + assert 'version = "0.2.1"' in project + assert 'app_version: str = "0.2.1"' in config assert len(migrations) == 30 assert migrations[-1].startswith("20260820_0030") @@ -85,8 +85,8 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback(): "Image rollback is **not** database rollback", "read-only root filesystem", "10001:10001", - "PortfolioHub v0.2.0 container contract", - "v0.2.0 adds migration `0030`", + "PortfolioHub v0.2.1 container contract", + "v0.2.1 includes migration `0030`", "trex-web v0.2.3 displays `strategy_nick`", ): assert value in text diff --git a/tests/test_v1_portfolios_crud.py b/tests/test_v1_portfolios_crud.py index a88c701..658b7f3 100644 --- a/tests/test_v1_portfolios_crud.py +++ b/tests/test_v1_portfolios_crud.py @@ -507,7 +507,7 @@ def test_delete_already_disabled_is_idempotent() -> None: # Issue #26: strategy catalog references on portfolio mutations. -def test_post_strategy_reference_errors_and_required_contract() -> None: +def test_post_strategy_reference_errors_and_optional_contract() -> None: repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) client = _make_client() with _with_repo(repo): @@ -516,7 +516,7 @@ def test_post_strategy_reference_errors_and_required_contract() -> None: json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "missing"}, headers=_auth("admin-tok"), ) - invalid = client.post( + without_strategy = client.post( "/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}, headers=_auth("admin-tok"), @@ -528,7 +528,8 @@ def test_post_strategy_reference_errors_and_required_contract() -> None: headers=_auth("admin-tok"), ) assert missing.status_code == 404 - assert invalid.status_code == 422 + assert without_strategy.status_code == 201 + assert without_strategy.json()["active_strategy_id"] is None assert inactive.status_code == 409 diff --git a/uv.lock b/uv.lock index b3413ce..e247b8b 100644 --- a/uv.lock +++ b/uv.lock @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "portfoliohub" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "alembic" },