505 lines
38 KiB
Markdown
505 lines
38 KiB
Markdown
# Portfolio Hub Runtime Flows
|
|
|
|
- Baseline Mode: observed
|
|
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
|
- Status: confirmed
|
|
- Owner: repository-maintainers
|
|
- Last Updated (KST): 2026-05-21 00:00
|
|
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004)
|
|
- Primary Runtime: mixed
|
|
|
|
## Project Summary
|
|
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
|
|
|
|
## Purpose
|
|
- This document is the current approved execution-flow baseline for the project.
|
|
- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates.
|
|
|
|
## Scope
|
|
- Covers representative command and runtime flows.
|
|
- Does not duplicate full acceptance criteria or low-level implementation details.
|
|
|
|
## Covered Entry Points
|
|
|
|
- uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks
|
|
- pfhub :: run operational CLI commands for health, users, portfolios, strategies, tags, service tokens, and daemon registry
|
|
- alembic upgrade head :: apply ordered database schema migrations for persistent environments
|
|
|
|
## Shared Components
|
|
|
|
- app/core/config.py Settings loading and environment normalization
|
|
- app/core/auth.py permission guards and auth context resolution
|
|
- app/services/* business logic for auth, users, portfolios, and strategy sets
|
|
- app/repositories/factory.py repository selection and Postgres config construction
|
|
- app/repositories/postgres.py Postgres persistence, schema bootstrap, and query execution
|
|
|
|
## Flow: `HTTP API Request Handling`
|
|
|
|
1. Uvicorn loads app.main:app, which creates the FastAPI application from create_app().
|
|
2. create_app() loads Settings, registers middleware and routers, and wires exception handlers.
|
|
3. During lifespan startup, the app optionally bootstraps an initial admin user when DB mode and bootstrap credentials are configured.
|
|
4. Incoming requests pass through auth and permission dependencies before services mutate state or query persistence.
|
|
5. Services call the repository factory, which returns PostgresPortfolioRepository only when PORTFOLIOHUB_DB_ENABLED=true.
|
|
6. Routes serialize domain results into app/schemas response models or return HTTP 204 for deletion flows.
|
|
|
|
## Flow: `CLI Operational Command Execution`
|
|
|
|
1. The pfhub entrypoint builds a Typer application and injects Settings, console output, and a repository factory into AppContext.
|
|
2. Command groups parse arguments, confirmations, and output format selection before invoking the relevant service or repository methods.
|
|
3. Database setup commands use the shared settings layer to target the configured PostgreSQL or TimescaleDB instance.
|
|
4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied.
|
|
5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
|
|
|
|
## Flow: `pfhub tag CLI Command Execution`
|
|
|
|
1. `pfhub tag create <name> [--user <username>] [--admin]`
|
|
- Resolves `--user` to `user_id` via `repo.get_user_by_username()`; raises ValueError if not found.
|
|
- Calls `repo.create_tag(name=name.strip(), is_admin_tag=--admin, created_by_user_id=resolved_id)`.
|
|
- Duplicate name → repo ValueError → console error, exit 1.
|
|
2. `pfhub tag list [--inactive]`
|
|
- Calls `repo.list_tags_master(include_inactive=--inactive)`.
|
|
- Renders Tag table (table format) or JSON array (--format json).
|
|
3. `pfhub tag deactivate <tag_id> --force`
|
|
- Without `--force`: prompts for confirmation; abort on refusal (exit 0).
|
|
- Calls `repo.get_tag_by_id(tag_id)`; if not found → ValueError → exit 1.
|
|
- If already inactive → prints "already inactive", returns (idempotent, exit 0).
|
|
- Calls `repo.deactivate_tag(tag_id)`.
|
|
4. `pfhub tag apply asset <asset_id> <tag_id>`
|
|
- Validates tag: get_tag_by_id → not found → ValueError; not is_admin_tag → ValueError; not is_active → ValueError.
|
|
- Calls `repo.append_portfolio_tag_id(asset_id, tag_id)`.
|
|
5. `pfhub tag apply portfolio <portfolio_id> <tag_id>`
|
|
- Validates tag: get_tag_by_id → not found → ValueError; not is_active → ValueError.
|
|
- Resolves portfolio: repo.get(portfolio_id) → not found → ValueError.
|
|
- Policy check: tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id; else → ValueError.
|
|
- Calls `repo.append_portfolio_tag_id(portfolio_id, tag_id)`.
|
|
6. `pfhub tag remove asset <asset_id> <tag_id> --force` / `pfhub tag remove portfolio <portfolio_id> <tag_id> --force`
|
|
- Without `--force`: prompts for confirmation; abort on refusal (exit 0).
|
|
- Calls `repo.remove_portfolio_tag_id(...)`.
|
|
7. `pfhub portfolio tag add/list/remove` — **removed in BAT-005**; Typer returns exit code 2 (no such command).
|
|
|
|
## Flow: `CLI Portfolio Command — Name+User Resolution`
|
|
|
|
1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `<name>` positional argument and `--user <username>` option.
|
|
2. `_resolve_portfolio_id(repository, name, user)` is called before any mutation:
|
|
a. Calls `get_user_by_username(user)` → raises ValueError if user not found.
|
|
b. Calls `get_portfolio_by_name_and_user(owner_user_id, name)` → raises ValueError if portfolio not found.
|
|
c. Returns `portfolio.portfolio_id` (UUID) for downstream use.
|
|
3. `create` command takes `<name>` as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.
|
|
4. `update --new-name <new_name>` renames the portfolio; `--force` is required to confirm the rename.
|
|
5. `comment` and `tag` sub-commands continue to accept `<portfolio_id>` (UUID) directly; they are outside the name+user resolution flow.
|
|
|
|
## Flow Constraints
|
|
|
|
- Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings.
|
|
- Permission levels read, trade-write, and admin must map consistently across API guards, issued tokens, and CLI mutations.
|
|
- Request validation and auth resolution should happen before persistence mutation or response rendering.
|
|
- The health endpoint remains unauthenticated so infrastructure can probe readiness without a token.
|
|
- `GET /health` response includes `db_enabled: bool` (REQ-035, dcy-005). 본 필드는 환경변수 `PORTFOLIOHUB_DB_ENABLED` 설정값을 그대로 노출하는 것이며 actual DB connectivity ping이 아니다. production-grade readiness probe (SELECT 1 ping 등)는 본 endpoint 범위 밖이며 별도 `/readiness` endpoint 분리는 후속 Discovery에서 다룬다.
|
|
|
|
## Flow: `v1 API — Tag Management (POST/GET/PATCH /v1/tags)`
|
|
|
|
1. **POST /v1/tags** (create tag):
|
|
- DB token required; trade-write or admin permission.
|
|
- Env token → HTTP 403.
|
|
- `name` is stripped and lowercased; `is_admin_tag` set if caller's permission is admin.
|
|
- `repo.create_tag(name, is_admin_tag, created_by_user_id=auth.user_id)` → UniqueViolation → HTTP 409.
|
|
- Returns HTTP 201 `TagItem`.
|
|
2. **GET /v1/tags** (list tags):
|
|
- DB token required; read permission.
|
|
- `?include_inactive=true` → `repo.list_tags_master(include_inactive=True)`; default returns active only.
|
|
- Returns HTTP 200 `TagListResponse {"tags": [...], "total": N}`.
|
|
3. **PATCH /v1/tags/{tag_id}/deactivate**:
|
|
- DB token required; read permission.
|
|
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
|
|
- Caller must be admin or tag creator (`auth.user_id == tag.created_by_user_id`); else → HTTP 403.
|
|
- If already inactive → return HTTP 200 immediately (idempotent).
|
|
- `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`.
|
|
|
|
## Flow: `v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)`
|
|
|
|
1. **POST /v1/portfolios** (create):
|
|
- 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.
|
|
- 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.
|
|
- `repo.soft_delete_portfolio(portfolio_id)` → sets `status="disabled"`, `managed_by_daemon_id=NULL`.
|
|
- Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with `?include_disabled=true`.
|
|
- Returns HTTP 200 `PortfolioItem` with `status="disabled"`.
|
|
|
|
## Flow: `v1 API — GET /v1/portfolios`
|
|
|
|
1. client token → HTTP 403. Any other valid token proceeds.
|
|
2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`.
|
|
3. `user`/`admin` token: admin with `?user_id=<id>` → filter by that user; else filter by `auth.user_id`.
|
|
4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter.
|
|
5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 유효 manual ledger를 집계한다.
|
|
6. `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
|
|
7. 각 `PortfolioItem`에 순투자현금(`net_contributed_capital`)과 누적 수익 인출액(`cumulative_profit_withdrawn`)을 결합한다. 집계 row가 없으면 두 값은 `0.0`이다.
|
|
8. Returns HTTP 200 `PortfolioListResponse`.
|
|
|
|
단건 `GET /v1/portfolios/{portfolio_id}/returns/summary`도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다.
|
|
|
|
## Flow: `v1 API — Soft Delete and Dependent Data Preservation`
|
|
|
|
1. `DELETE /v1/portfolios/{portfolio_id}` sets `portfolios.status="disabled"` and `portfolios.managed_by_daemon_id=NULL`.
|
|
2. `portfolio_comments` rows keyed by `portfolio_id` are NOT deleted; they remain retrievable via `GET /v1/portfolios/{id}/comments` even after soft delete.
|
|
3. `portfolios.tag_ids UUID[]` is NOT cleared; existing tag references are preserved.
|
|
4. Disabled portfolios are excluded from `GET /v1/portfolios` responses by default (`?include_disabled=false`).
|
|
5. Write mutations (PATCH non-status fields, POST/PATCH/DELETE tags, POST/PATCH/DELETE comments) on a disabled portfolio → HTTP 409. READ operations (GET tags, GET comments) remain allowed.
|
|
6. Status restoration: `PATCH /v1/portfolios/{id}` with `{"status": "enabled"}` is the only allowed mutation on a disabled portfolio; this re-enables the portfolio.
|
|
|
|
## Flow: `v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)`
|
|
|
|
1. **POST /v1/assets/{asset_id}/tags** (apply tag to asset — admin only):
|
|
- DB token required; admin permission (`require_admin`).
|
|
- `repo.get(asset_id)` → ValueError → HTTP 404.
|
|
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
|
|
- `not tag.is_admin_tag` → HTTP 403; `not tag.is_active` → HTTP 403.
|
|
- `repo.append_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
|
|
2. **DELETE /v1/assets/{asset_id}/tags/{tag_id}** (remove tag from asset — admin only):
|
|
- DB token required; admin permission.
|
|
- `repo.get(asset_id)` → ValueError → HTTP 404.
|
|
- `repo.remove_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
|
|
3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin):
|
|
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
|
|
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
|
|
- Caller must be portfolio owner or admin; else → HTTP 403.
|
|
- `portfolio.status == "disabled"` → HTTP 409 (disabled portfolio write blocked).
|
|
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
|
|
- `not tag.is_active` → HTTP 403.
|
|
- Policy: `tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id`; else → HTTP 403.
|
|
- `repo.append_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
|
|
4. **DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}** (remove tag from portfolio — owner or admin):
|
|
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
|
|
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
|
|
- Caller must be portfolio owner or admin; else → HTTP 403.
|
|
- `portfolio.status == "disabled"` → HTTP 409.
|
|
- `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
|
|
5. **GET /v1/portfolios/{portfolio_id}/tags** (list applied tags — REQ-030, BAT-010):
|
|
- `require_read`; service/client → HTTP 403 (inline).
|
|
- `_get_portfolio_or_404(auth, portfolio_id, repo)` → ownership check; disabled portfolio allowed (GET).
|
|
- `repo.get_tags_by_ids(portfolio.tag_ids)` → dict; empty tag_ids → `PortfolioTagListResponse(tags=[], total=0)`.
|
|
- Returns HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": <int>}`.
|
|
|
|
## Flow: `v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)`
|
|
|
|
1. **POST /v1/portfolios/{portfolio_id}/comments** (create):
|
|
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
|
|
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
|
|
- `repo.create_comment(portfolio_id, content=body.content)` → HTTP 201 `PortfolioCommentItem`.
|
|
2. **GET /v1/portfolios/{portfolio_id}/comments** (list):
|
|
- `require_read`; service/client → HTTP 403 (inline).
|
|
- `_get_portfolio_or_404` → ownership check; disabled portfolio GET is allowed.
|
|
- `repo.list_comments(portfolio_id)` → list in `created_at desc` order.
|
|
- Returns HTTP 200 `PortfolioCommentListResponse {"comments": [...], "total": <int>}`.
|
|
3. **PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}** (update):
|
|
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
|
|
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
|
|
- `repo.update_comment(portfolio_id, comment_id, content=body.content)` → ValueError → HTTP 404.
|
|
- Returns HTTP 200 `PortfolioCommentItem`.
|
|
4. **DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}** (delete):
|
|
- `require_read`; `_require_write_token` blocks service/client → HTTP 403.
|
|
- `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409.
|
|
- `repo.delete_comment(portfolio_id, comment_id)` → ValueError → HTTP 404.
|
|
- Returns HTTP 204 (no content).
|
|
|
|
## Flow: `v1 API — DB Token Authentication`
|
|
|
|
1. v1 endpoint receives Bearer token from Authorization header.
|
|
2. `require_read` / `require_admin` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`.
|
|
3. Env tokens → HTTP 403 (all /v1 endpoints require DB tokens).
|
|
4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, `runtime_id`, and `daemon_id`.
|
|
- `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006)
|
|
- `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed
|
|
|
|
## Flow: `v1 API — GET /v1/assets` (admin asset catalog)
|
|
|
|
1. Admin token required (`require_admin`). Non-admin (user/service/client) callers receive HTTP 403.
|
|
2. Query params: `?status=<active|inactive|all>` (default `active`), `?market=<code>` (optional). `status` outside the allow-list → HTTP 422.
|
|
3. `repo.list_assets()` → in-memory filter by `status` (skipped when `all`) and by `market` (`<code> in asset.markets`).
|
|
4. Tag archived 합성: 결과 자산들의 `tag_ids` union을 모아 `repo.get_tags_by_ids(...)`로 1회 조회. `tags.is_active=False` 또는 마스터에 없는 tag_id는 `archived=True`로 응답. 빈 배열은 그대로 빈 배열.
|
|
5. Returns `AssetCatalogListResponse` with `items[]` and `total`. Each item: `asset_id, symbol, markets, display_name, status, tag_ids: [{tag_id, archived}], created_at, updated_at`. Portfolio 필드(`cash`, `quantity`, `owner_user_id` 등)는 포함하지 않는다 — 포지션 조회는 `GET /v1/portfolios`(REQ-028).
|
|
|
|
## Flow: `Admin Asset Catalog CRUD — POST/PATCH /v1/assets`
|
|
|
|
1. Admin token required (`require_admin`).
|
|
2. `POST /v1/assets`: `repo.insert_asset(symbol, markets, display_name, status)` → 201 `AssetCatalogResponse`.
|
|
3. `PATCH /v1/assets/{asset_id}`: `repo.update_asset(asset_id, markets, display_name, status)` → 200 `AssetCatalogResponse`.
|
|
4. `PATCH /v1/assets/{asset_id}/deactivate`: `repo.update_asset(asset_id, status="inactive")` → 200 `AssetCatalogResponse`.
|
|
5. Non-admin callers receive HTTP 403.
|
|
|
|
## Flow: `v1 API — GET /v1/user_info`
|
|
|
|
1. Service-token auth required. Available to all service tokens including unbound (daemon_id=NULL); no daemon-id guard.
|
|
2. `get_user()` fetches user record by `user_id`.
|
|
3. `list_broker_credentials()` fetches all broker credential rows for the user.
|
|
4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`.
|
|
5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error).
|
|
|
|
## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011)
|
|
|
|
1. Service-token auth required with `trade-write` permission (`_require_service_token` dependency).
|
|
2. `token_source != "db"` or `token_type != "service"` → HTTP 403.
|
|
3. `auth.daemon_id is None` → HTTP 403 fail-closed ("daemon_id not assigned to this token").
|
|
4. `repo.get(portfolio_id)` → exception → HTTP 404.
|
|
5. `portfolio.status == "disabled"` → HTTP 409 (disabled write 차단, REQ-027 정합).
|
|
6. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
|
|
7. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
|
|
8. `insert_ledger_trade(asset_id=portfolio_id, ...)` with idempotency key `(portfolio_id, order_id, execution_id)`.
|
|
9. New record → HTTP 201 + `status="new"`.
|
|
10. Duplicate key + identical payload → HTTP 200 + `status="duplicate"`.
|
|
11. Duplicate key + different payload → HTTP 409.
|
|
- 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404.
|
|
|
|
|
|
|
|
## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
|
|
|
|
1. DB user/session token auth required. Service/client/env tokens fail closed with HTTP 403.
|
|
2. `_get_portfolio_or_404()` enforces owner scope; non-owner receives HTTP 403.
|
|
3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`.
|
|
4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker.
|
|
5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot.
|
|
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code.
|
|
7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
|
|
8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
|
|
9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
|
|
10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
|
|
|
|
## Flow: `v1 API — POST /auth/service-tokens`
|
|
|
|
1. Admin token auth required (`require_admin` dependency).
|
|
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`, `daemon_id` (optional; BAT-006/REQ-018 forward 보정).
|
|
3. If `daemon_id` provided: `get_daemon_by_id()` → 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422.
|
|
4. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id` and `daemon_id`.
|
|
5. Returns HTTP 201 with `token` (raw value), `runtime_id`, `daemon_id`, and `auth_token` metadata.
|
|
|
|
## Flow: `Daemon Registry — Create and Bind Service Token`
|
|
|
|
1. Operator creates a daemon via `pfhub daemon create <daemon_slug> --user <username>` (optionally `--display-name <name>`).
|
|
- `AuthService` is not involved; the repository creates the `Daemon` record directly with `owner_user_id` resolved from `username`.
|
|
- Returns `daemon.id` (server-generated UUID), `daemon_slug`, `is_active=True`.
|
|
2. Operator issues a service token bound to the daemon via `pfhub service-token issue <name> --user <username> --daemon-id <daemon_id>`.
|
|
- `AuthService.issue_service_token()` validates: daemon exists, is_active=True, and `owner_user_id` matches the token's user.
|
|
- Unknown or inactive daemon → ValueError ("존재하지 않거나 비활성").
|
|
- `create_auth_token()` persists the token with `daemon_id` set.
|
|
3. Operator assigns an asset to the daemon via portfolio update (`managed_by_daemon_id` column set).
|
|
4. `GET /v1/assets` with the bound token returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`.
|
|
5. `POST /v1/portfolios/{portfolio_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029).
|
|
|
|
## Flow: `pfhub service-token rebind` (BAT-007/REQ-023)
|
|
|
|
1. Operator calls `pfhub service-token rebind <name> --daemon-id <daemon_id>`.
|
|
2. `AuthService.rebind_service_token(name, daemon_id)`:
|
|
a. `get_service_token_by_name(name)` → None → ValueError.
|
|
b. `get_daemon_by_id(daemon_id)` → None 또는 is_active=false → ValueError.
|
|
c. `daemon.owner_user_id ≠ token.user_id` → ValueError ("해당 토큰 사용자에 속하지 않습니다").
|
|
d. `repository.rebind_service_token(name, daemon_id)` → token.daemon_id 갱신, runtime_id 불변.
|
|
3. Returns `(previous_daemon_id, rebound_token)`.
|
|
|
|
## Flow: `pfhub daemon update --deactivate` (BAT-007/REQ-022)
|
|
|
|
1. Operator calls `pfhub daemon update <daemon_id> --deactivate [--force]`.
|
|
2. `--force` 없으면 확인 프롬프트; 거절 시 exit 0.
|
|
3. `repo.update_daemon(daemon_id, is_active=False)` — daemon is_active=false 설정.
|
|
4. `repo.revoke_tokens_by_daemon_id(daemon_id)` — 바인딩된 모든 active service token에 `revoked_at=NOW()` 설정.
|
|
5. Console에 revoked count 출력.
|
|
|
|
## Flow: `PATCH /v1/admin/daemons/{daemon_id}` — Owner Transfer
|
|
|
|
1. Admin authentication is required; an unknown daemon returns HTTP 404.
|
|
2. For an effective `owner_user_id` change, `update_daemon_owner()` starts one transaction and locks the daemon row `FOR UPDATE`.
|
|
3. It checks `portfolios.managed_by_daemon_id` while holding that lock.
|
|
- A reference exists: raise the dedicated conflict and return HTTP 409. Daemon owner, portfolio assignment, and broker credentials remain unchanged.
|
|
- No reference exists: delete credentials, update the daemon owner, and commit the existing atomic credential-invalidation flow.
|
|
4. Portfolio assignment uses `FOR SHARE` on the same daemon row before INSERT/UPDATE. Thus transfer-first makes the old owner's pending assignment fail as foreign; assignment-first makes the transfer fail HTTP 409. No automatic unassignment, portfolio owner migration, or alternate-daemon reassignment occurs.
|
|
|
|
## Flow: `Service-Token Rotate — daemon_id Invariant`
|
|
|
|
1. Operator calls `pfhub service-token rotate <name> --force`.
|
|
2. `AuthService.rotate_service_token(name)` delegates to `repository.rotate_service_token(name)`.
|
|
3. Repository looks up the existing token by name; raises ValueError if not found.
|
|
4. Old token is revoked (`revoked_at = NOW()`).
|
|
5. New token row is created with:
|
|
- New raw token value (generate_token()), new `token_hash`, new `token_prefix`.
|
|
- New `runtime_id` (uuid4) — per-token, changes on every rotate.
|
|
- Same `daemon_id` as the old token — stable across rotates (daemon_id invariant).
|
|
- Same `user_id`, `name`, `permission`, `token_type`, `expires_at`.
|
|
6. Returns raw token (shown once) and new `auth_token` metadata including new `runtime_id`.
|
|
7. `GET /v1/user_info` called with the new token returns the same `daemon_id` but a new `runtime_id`.
|
|
|
|
## Flow: `Application Startup Validation`
|
|
|
|
1. `lifespan` startup hook runs `_bootstrap_initial_user()` if DB enabled and bootstrap credentials configured.
|
|
2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`.
|
|
3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup.
|
|
|
|
## Flow: `Local DB Initialization and Validation`
|
|
|
|
### Scenario A — Fresh DB (DB 미존재 상태)
|
|
|
|
1. `docker compose -f docker-compose.local-test.yml up -d` — TimescaleDB 컨테이너 시작.
|
|
2. 컨테이너 준비 확인: `docker compose -f docker-compose.local-test.yml ps` → `portfolio-hub-timescaledb` 상태가 `healthy` 인지 확인.
|
|
3. `pfhub_DB_ENABLED=true pfhub init-db` — 대상 DB 생성 + Alembic `upgrade head` 실행.
|
|
- 기대 출력: `database: portfolio_hub`, `created: True`, `schema_initialized: True`, `migration_applied: head`
|
|
- pass 기준: exit code 0, 위 필드 모두 출력
|
|
- fail 기준: connection refused, permission denied, Alembic revision 오류
|
|
4. `pfhub_DB_ENABLED=true pfhub health-check` — DB ping 성공 확인.
|
|
- 기대 출력: `db_enabled: True`, `repository: PostgresPortfolioRepository`
|
|
- pass 기준: exit code 0, `db_enabled: True`
|
|
5. Smoke flow: 사용자 생성 → portfolio 생성 → `portfolio list --user <username>` 확인.
|
|
- pass 기준: 생성된 portfolio가 list 출력에 포함됨
|
|
|
|
### Scenario B — runtime-precreated schema (ensure_schema() 선행)
|
|
|
|
1. DB 컨테이너 시작 (Scenario A 1~2 동일).
|
|
2. `pfhub_DB_ENABLED=true uvicorn app.main:app` — App 기동. `ensure_schema()`가 핵심 테이블 생성.
|
|
3. `pfhub_DB_ENABLED=true pfhub migrate-db` — `bootstrap_or_upgrade_to_head()` 실행.
|
|
- 내부 동작: `alembic_version` 테이블 없음 → 기존 핵심 테이블(portfolios 등) 존재 → legacy stamp → head로 stamp + 미보유 객체(broker_credentials, ledger_trades, runtime_id 등) upgrade
|
|
- 기대 출력: `migration_applied: head`
|
|
- pass 기준: exit code 0, 이후 `pfhub health-check` exit code 0
|
|
4. `pfhub_DB_ENABLED=true pfhub health-check` 재확인.
|
|
|
|
### Repeatable Run Criteria
|
|
|
|
- 모든 시나리오는 컨테이너 볼륨 초기화(`docker compose down -v`) 후 재실행 가능.
|
|
- 출력 형식: `pfhub --output json <command>` 로 JSON 출력 수집 시 필드명이 고정됨.
|
|
- 로그 수집 위치: `docker compose logs portfolio-hub-timescaledb` — DB 측 오류 시 참조.
|
|
- Evidence 형식: exit code + 주요 필드 값 스크린샷 또는 텍스트 캡처 → batch verification.md에 기록.
|
|
|
|
## Flow: `Alembic vs ensure_schema() Schema Ownership Boundary`
|
|
|
|
`ensure_schema()` (postgres.py)와 Alembic revision 양쪽이 다루는 객체가 존재한다. 충돌은 `bootstrap_or_upgrade_to_head()`의 legacy stamp 경로 + 각 revision의 IF NOT EXISTS guard로 처리된다.
|
|
|
|
### 경계 규칙
|
|
|
|
1. **ensure_schema() 선행 가능 객체**: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨. BAT-010: `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id`, `daemons` 테이블도 ensure_schema()에 ADD/CREATE IF NOT EXISTS guard로 추가됨.
|
|
2. **Alembic만 소유 (ensure_schema()에 없음)**: `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades`. ensure_schema()만 실행한 환경에서 v1 API 사용 불가.
|
|
3. **BAT-010 migration 20260513_0016**: `daemons` 테이블, `auth_tokens.daemon_id`, `portfolios.managed_by_daemon_id` 추가. down_revision=20260511_0015. ensure_schema() 선행 환경에서도 migration 적용 시 IF NOT EXISTS guard로 safe.
|
|
4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행.
|
|
5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판).
|
|
|
|
## Token Expiry and Rotation Policy
|
|
|
|
(REQ-021/BAT-008 — dcy-004 FR-6, OQ-5 결정: 현행 TTL 유지)
|
|
|
|
### Type별 기본 TTL
|
|
|
|
| Token Type | 기본 만료 | no_expiry 허용 | 비고 |
|
|
|------------|-----------|----------------|------|
|
|
| session | `NOW() + settings.session_token_ttl_hours` | ✗ (거부) | `AuthService.login()` |
|
|
| service | `NOW() + 365일` (기본) | ✓ | `AuthService.issue_service_token(no_expiry=True)` |
|
|
| env | 없음 (TTL 미적용) | N/A | `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수 |
|
|
|
|
### no_expiry 허용 범위
|
|
|
|
- service token: `pfhub service-token issue <name> --no-expiry` 또는 POST /auth/service-tokens body `"no_expiry": true` → expires_at=NULL 발급.
|
|
- session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부.
|
|
- ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정.
|
|
- `no_expiry`와 `expires_in_days`를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다").
|
|
|
|
### 강제 회전 주기
|
|
|
|
- session / service: 강제 회전 주기 없음.
|
|
- env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조).
|
|
|
|
### 정책 위반 처리
|
|
|
|
- no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요).
|
|
- 코드 reference: `app/services/auth_service.py` `AuthService.issue_service_token()` no_expiry + expires_in_days 분기 검증.
|
|
|
|
## env admin token 운영 SOP
|
|
|
|
(REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음)
|
|
|
|
### 적용 범위
|
|
|
|
`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용.
|
|
|
|
### 권장 회전 주기
|
|
|
|
분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정.
|
|
|
|
### 회전 트리거 조건
|
|
|
|
- (a) 정기: 분기마다
|
|
- (b) token leak 의심
|
|
- (c) 운영자 교체·퇴사
|
|
- (d) 보안 사고 대응
|
|
|
|
### 다단계 회전 절차 (7단계)
|
|
|
|
1. 강한 무작위 token 생성:
|
|
```
|
|
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
```
|
|
2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 **추가** (다중 token 동시 운영):
|
|
```
|
|
PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token>
|
|
```
|
|
3. service rolling restart (구 token 계속 유효).
|
|
4. 신규 token으로 admin 호출 검증 (예: `GET /health`, admin 작업 sanity check).
|
|
5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 **제거**:
|
|
```
|
|
PORTFOLIOHUB_ADMIN_TOKENS=<신규_token>
|
|
```
|
|
6. service rolling restart (구 token 무효화).
|
|
7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과.
|
|
|
|
### rollback 절차
|
|
|
|
- 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart.
|
|
- fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시).
|
|
|
|
### Token 강도 가이드
|
|
|
|
- 최소 48-byte URL-safe 무작위 (`secrets.token_urlsafe(48)` 이상).
|
|
- 평문 단어, 예측 가능한 패턴 (예: `admin-token-789`) 금지.
|
|
- dev/test 환경에서도 약한 placeholder 사용 금지.
|
|
|
|
### 운영자 권한
|
|
|
|
- `PORTFOLIOHUB_ADMIN_TOKENS` 추가/제거는 인프라 운영자 권한.
|
|
- 현행 코드(`app/core/auth.py:_resolve_permission`)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능.
|
|
|
|
## Current Gaps / Planned Changes
|
|
|
|
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
|
|
- Redis and Loki are configured as supporting infrastructure but are not yet represented as explicit runtime flows.
|
|
- Broker credential write path (upsert) is available only via CLI; no HTTP API endpoint for credential registration exists yet.
|
|
|
|
## Change Log
|
|
|
|
- 2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 정합화: `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` 호출을 `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. portfolios 테이블 mutation 호출은 portfolio 라우트(`/v1/portfolios/{id}/tags`) 전용으로 한정. HTTP contract 무변, BAT-012 신설 `assets.tag_ids` 컬럼이 비로소 정상 채워짐.
|
|
- 2026-05-21 KST: BAT-012 (REQ-002 / REQ-010 AC-9~12) — GET /v1/assets 흐름 재정의: 기존 token_type 분기 portfolio 조회 설명을 admin asset catalog 조회로 교체; tag_ids 배열 + archived 합성 흐름 신설(tags 마스터 is_active 조회로 비활성·미존재 tag_id를 `archived=True`로 응답).
|
|
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신.
|
|
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신.
|
|
- 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024).
|
|
- 2026-05-20 KST: bat-005 — REQ-008 AC-4: added pfhub tag CLI flow; added v1 tag management flow (POST/GET/PATCH /v1/tags); added v1 tag apply/remove flow (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags); updated Covered Entry Points (tags).
|
|
- 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand).
|
|
- 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated.
|
|
- 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow.
|
|
- 2026-05-11 KST: bat-006 — Added CLI portfolio name+user resolution flow; updated GET /v1/assets flow to note asset_id is UUID after BAT-006 migration.
|
|
- 2026-05-01 KST: bat-001 — Added v1 API flows (service-token auth, GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens), startup validation flow.
|
|
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|