217 lines
16 KiB
Markdown
217 lines
16 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-13 00:00
|
|
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010
|
|
- 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, 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: `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.
|
|
|
|
## 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`.
|
|
|
|
## Flow: `v1 API — GET /v1/assets`
|
|
|
|
1. DB token required; `token_type` drives branching:
|
|
- `service`: daemon-id guard — if `auth.daemon_id is None` → HTTP 403 (fail-closed). `list_portfolios_with_strategies(owner_user_id=auth.user_id, managed_by_daemon_id=auth.daemon_id, include_disabled=False, market=market)`.
|
|
- `user`: `list_portfolios_with_strategies(owner_user_id=auth.user_id, include_disabled=True, market=market)`.
|
|
- `admin`: `list_portfolios_with_strategies(owner_user_id=filter_user_id, include_disabled=True, market=market)` where `filter_user_id = ?user_id param if provided, else auth.user_id`.
|
|
2. Optional `?status=<value>` query param post-filters portfolios by status field.
|
|
3. Optional `?market=<code>` query param filters by `assets.markets` array (SQL: `%s = ANY(a.markets)`).
|
|
4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
|
|
5. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
|
|
6. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID).
|
|
|
|
## 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/assets/{asset_id}/ledger/trades`
|
|
|
|
1. Service-token auth required with `trade-write` permission.
|
|
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing.
|
|
3. Daemon assignment authorization (fail-closed, three checks in order):
|
|
a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token").
|
|
b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon").
|
|
c. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment").
|
|
4. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
|
|
5. If new record: returns HTTP 201 with `status="new"`.
|
|
6. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
|
|
7. If duplicate key with different payload: returns HTTP 409 with conflict detail.
|
|
|
|
## 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`.
|
|
3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`.
|
|
4. Returns HTTP 201 with `token` (raw value), `runtime_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/assets/{asset_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade.
|
|
|
|
## 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 오판).
|
|
|
|
## 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-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. |