Add ProofLine criteria documentation and Strategy Catalog v1 implementation

- Add acceptance criteria documentation (AC-0013 through AC-0023)
- Add Line-0003 discovery documentation
- Implement Strategy Catalog v1 with new database migration
- Update portfolio API endpoints and schemas
- Refactor strategy and portfolio services
- Add strategy management CLI commands
- Update domain models with strategy catalog support
- Add comprehensive tests for v1 strategies
- Update design documentation (data model, interfaces, runtime flows)
This commit is contained in:
2026-08-15 11:18:31 +09:00
parent 5fc389dea8
commit 6e02d92282
48 changed files with 2114 additions and 393 deletions
+25 -1
View File
@@ -102,6 +102,28 @@
- Only `status="active"` assets can be referenced when creating a new portfolio.
- Deactivated assets do not affect existing portfolios; only new creation is blocked.
## Entity: `StrategySet`
- Purpose: stable, reusable strategy catalog entry selected by portfolio mutations through its ID
- Source of Truth: `strategy_sets`; migration `20260814_0029` adds lifecycle and revision fields and seeds/reuses the canonical `target_value` / `{}` entry
- Lifecycle: active → updated with monotonically increasing revision → optionally inactive → deleted only when unreferenced
### Key Fields
- id :: text :: stable strategy ID; never changes during PATCH
- name/config :: text :: strategy identity/configuration pair; unique together
- nick :: text :: unique derived display key (`name:sha1(config)[:12]`), recomputed from the latest row state under the PATCH row lock
- status :: text :: `active` or `inactive`; inactive entries remain valid historical references but cannot be newly assigned
- revision :: integer :: starts at 1 and increments once per effective mutation; no-op PATCH does not increment
- created_at/updated_at :: timestamptz :: creation and last effective mutation timestamps
### Relationships and Deletion
- `portfolios.active_strategy_id` and entries in `portfolios.avabile_strategies` reference strategy IDs.
- New selection uses a strategy `FOR SHARE` lock in the same transaction as portfolio persistence; PATCH/DELETE use `FOR UPDATE` so concurrent mutation, inactivation, and deletion serialize.
- Deletion is rejected while either reference form exists. The reference check and delete share one transaction, preventing check-then-delete orphan races. Inactivation does not rewrite existing portfolio references.
- Trade snapshot persistence does not write either strategy-selection column. Strategy selection is owned only by portfolio create/update paths, so stale BUY/SELL state cannot resurrect a deleted strategy reference.
## Entity: `Portfolio`
- Purpose: represent an owned investment portfolio with balances, strategy references, tags, comments, and trading history
@@ -116,7 +138,8 @@
- symbol :: text :: display symbol populated via JOIN on assets (read-only, not stored in portfolios)
- status :: text :: `"enabled"` (active, default) | `"disabled"` (soft-deleted); set to `"disabled"` by `DELETE /v1/portfolios/{id}`; only `status` can be mutated on a disabled portfolio (restoration via PATCH); all other writes → HTTP 409
- owner_user_id :: text|null :: owning user reference for attribution and authorization
- active_strategy_id :: text|null :: currently selected strategy-set reference
- active_strategy_id :: text|null :: currently selected stable strategy-catalog ID; create requires an active strategy and update may select another active strategy
- strategy_revision :: integer|null :: read-only revision joined from the currently referenced StrategySet; subsequent API reads expose the latest committed shared config revision
- cash_balance :: double precision :: current cash snapshot after the latest ledger event
- asset_quantity :: double precision :: current holdings snapshot after the latest ledger event
- managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); set to NULL on soft delete; added by migration 20260513_0016
@@ -333,6 +356,7 @@
- Alembic revisions in migrations/versions/ are the ordered schema history for persistent environments.
- app/repositories/postgres.py includes ensure_schema() for bootstrap safety, but long-term schema evolution should run through Alembic.
- Current Alembic head is `20260814_0029`; its strategy columns, default seed, and constraints are guarded for ensure_schema-first and repeatable execution.
- Comments, tags, auth tokens, and ledger rows are normalized into dedicated tables keyed to users or portfolios.
## Current Gaps / Planned Changes
+20 -7
View File
@@ -56,7 +56,7 @@
### Compatibility Rules
- Route prefixes /health, /auth, /users, /strategy-sets, and /portfolios define the current internal API surface.
- Route prefixes `/health`, `/auth`, `/users`, and `/portfolios` define the unversioned internal API surface. The legacy `/strategy-sets` routes are removed; strategy catalog consumers use `/v1/strategies` and admins use `/v1/admin/strategies`.
- Permission semantics read, trade-write, and admin must stay aligned across auth guards and token metadata.
- Breaking route or schema changes require REQ and Batch documentation updates before release.
@@ -76,6 +76,8 @@
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly
- Tag commands (bat-005) :: `pfhub tag create/list/deactivate/apply/remove` operate on the tags master table; `pfhub portfolio tag add/list/remove` has been removed (breaking change)
- Portfolio strategy selection :: `pfhub portfolio create` requires `--strategy-id`; `portfolio update` accepts optional `--strategy-id`. Strategy names/configs are not accepted on portfolio mutations.
- Strategy catalog commands :: `pfhub strategy list/create/update/delete`; list and mutation output includes `status` and `revision`, update preserves the strategy ID, and referenced strategies cannot be deleted.
### Outputs
@@ -299,18 +301,29 @@
### Endpoint: `POST /v1/portfolios`
- Auth: any valid token; service/client → HTTP 403
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", "owner_user_id": "<str|null>", "broker": "<str>", "status": "<str>", "name": "<str>", ...}`
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<required strategy ID>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
- 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.
- Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown; HTTP 409 if the strategy is inactive; HTTP 422 for a missing/invalid `strategy_id` or an unknown/inactive asset
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
- Auth: any valid token; service/client → HTTP 403; ownership required
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- Response: HTTP 200 `PortfolioItem {"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", ...}`
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_id": "<str|null>", "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- Response: HTTP 200 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
- Note: response `symbol` is emitted from the joined asset row referenced by `asset_id`.
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or strategy is not found; HTTP 409 if the portfolio is disabled for the mutation or the selected strategy is inactive; HTTP 422 for an invalid request body
## Interface: `v1 Strategy Catalog API`
- `GET /v1/strategies` and `GET /v1/strategies/{strategy_id}` require read permission and expose active strategies only.
- `GET /v1/admin/strategies[/{strategy_id}]` and admin `POST`, `PATCH`, `DELETE` require admin permission and expose both active and inactive entries.
- Create body: `{"name": "<str>", "config": {<dict>}, "status": "active|inactive"}`; response is `StrategyResponse {strategy_id, name, config, status, revision, created_at, updated_at}`.
- Patch accepts at least one of `name`, `config`, or `status`. An effective no-op preserves `revision`; a real mutation increments it while preserving `strategy_id` and keeping the derived `nick` consistent with name/config.
- Strategy PATCH locks the catalog row (`FOR UPDATE`) through the mutation transaction. Concurrent partial PATCH requests therefore serialize and derive `nick` from the latest committed name/config pair.
- Inactive strategies remain readable to admins and remain attached to existing portfolios, but cannot be selected for new or updated portfolio assignments.
- New portfolio selection takes a `FOR SHARE` lock on the active strategy in the same transaction as the portfolio INSERT/UPDATE. PATCH/DELETE take `FOR UPDATE`, preventing concurrent inactivation/deletion from racing the selection; DELETE also checks both reference forms and deletes in that transaction.
- Delete returns HTTP 409 while any portfolio references the strategy. Unknown IDs return HTTP 404; schema validation returns HTTP 422; conflicts return HTTP 409.
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete)
+1 -1
View File
@@ -138,7 +138,7 @@
|-----------|-----------|------|
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 |
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic 실행 시 충돌 없음 |
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets (`status`, `revision`, `updated_at` 포함), portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic `20260814_0029` 실행 시 충돌 없음; 기본 전략 seed/constraints도 존재 여부 guard 적용 |
### 허용 실행 순서
+12 -1
View File
@@ -121,13 +121,24 @@
- Any valid token; `_require_write_token` blocks service/client (`token_type ∈ {service, client}` → HTTP 403).
- admin with `body.user_id` → creates portfolio for target user; otherwise uses `auth.user_id`.
- `repo.get_asset_by_id(asset_id)` → None or inactive → HTTP 422.
- `repo.get_or_create_strategy_set(strategy_name, strategy_params_json)` → resolves strategy.
- Required `strategy_id` is resolved with `repo.get_strategy_set(strategy_id)`; unknown → HTTP 404, inactive → HTTP 409. Portfolio mutation never creates a strategy implicitly.
- `repo.create_portfolio(...)` with `status="enabled"` → HTTP 201 `PortfolioItem`.
2. **PATCH /v1/portfolios/{portfolio_id}** (update):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → checks ownership; non-owner non-admin → HTTP 403.
- If `portfolio.status == "disabled"`: non-status fields present → HTTP 409 (only status restoration allowed).
- Optional `strategy_id` is resolved before `repo.update_portfolio(...)`; unknown → HTTP 404, inactive → HTTP 409. The stable catalog ID becomes `active_strategy_id`.
- `repo.update_portfolio(portfolio_id, **fields)` → HTTP 200 `PortfolioItem`.
## Flow: `v1 Strategy Catalog`
1. Read callers list active entries at `GET /v1/strategies`; admin reads include inactive entries under `/v1/admin/strategies`.
2. Admin create canonicalizes the JSON config. A new row stores the requested status at revision 1; a matching existing name/config reuses its stable ID and applies an actual status change through the normal update path.
3. Admin PATCH requires at least one field. The row is locked `FOR UPDATE`; effective no-ops keep the current revision, while concurrent real changes serialize, preserve ID, recompute the derived nick from the latest committed name/config, increment revision once, and update `updated_at`.
4. Inactive entries are retained for existing portfolio references but rejected for new assignments.
5. Portfolio selection locks an active strategy `FOR SHARE` in the same transaction as portfolio INSERT/UPDATE. Admin PATCH/DELETE lock it `FOR UPDATE`; DELETE checks both `active_strategy_id` and `avabile_strategies` and deletes in one transaction. Any reference returns HTTP 409, otherwise deletion returns HTTP 204 without an orphan or inactive-selection race.
6. Legacy unversioned `/strategy-sets` routes are not registered.
7. BUY/SELL persistence updates balances, price, buffer, timestamps, and ledger atomically but never writes `active_strategy_id` or `avabile_strategies`; a stale trade snapshot therefore cannot restore an old or deleted catalog assignment after a concurrent strategy change.
3. **DELETE /v1/portfolios/{portfolio_id}** (soft delete):
- `_require_write_token` blocks service/client → HTTP 403.
- `_get_portfolio_or_404` → ownership check.