BAT-004 (REQ-004, REQ-005):
- app/api/v1_tags.py: POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{id}/deactivate
- app/api/v1_portfolios.py: POST/DELETE /v1/portfolios/{id}/tags
- app/api/v1_assets.py: POST/DELETE /v1/assets/{id}/tags 엔드포인트 추가
- app/schemas/v1.py: TagItem, TagCreateRequest, TagListResponse, TagApplyRequest/Response 추가
- app/repositories/portfolio_repository.py, postgres.py: append/remove_portfolio_tag_id 추가
- app/main.py: v1_portfolios_router, v1_tags_router 등록
- tests/test_tags_http_api.py: 22개 HTTP API 테스트
BAT-005 (REQ-007, REQ-008):
- app/cli/commands/tag.py: pfhub tag create/list/deactivate/apply/remove 7개 커맨드 신규
- app/cli/main.py: tag_app 등록
- app/cli/commands/portfolio.py: pfhub portfolio tag add/list/remove 완전 제거(breaking change)
- app/cli/core/utils.py: print_tag_table + list[Tag] render 분기 추가
- tests/test_tags_cli.py: REQ-007 AC-1~9 커버 24개 CLI 테스트 신규
- tests/test_cli.py: 제거된 portfolio tag 테스트 2개 삭제
- docs/data-model.md, interface-contract.md, project-structure.md, runtime-flows.md: 태그 모델 baseline 갱신(REQ-008)
- docs/batches/: BAT-004, BAT-005 전체 산출물(release-candidate)
247 passed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
21 KiB
21 KiB
Portfolio Hub Runtime Flows
- Baseline Mode: observed
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed
- Owner: repository-maintainers
- Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005
- 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
- Uvicorn loads app.main:app, which creates the FastAPI application from create_app().
- create_app() loads Settings, registers middleware and routers, and wires exception handlers.
- During lifespan startup, the app optionally bootstraps an initial admin user when DB mode and bootstrap credentials are configured.
- Incoming requests pass through auth and permission dependencies before services mutate state or query persistence.
- Services call the repository factory, which returns PostgresPortfolioRepository only when PORTFOLIOHUB_DB_ENABLED=true.
- Routes serialize domain results into app/schemas response models or return HTTP 204 for deletion flows.
Flow: CLI Operational Command Execution
- The pfhub entrypoint builds a Typer application and injects Settings, console output, and a repository factory into AppContext.
- Command groups parse arguments, confirmations, and output format selection before invoking the relevant service or repository methods.
- Database setup commands use the shared settings layer to target the configured PostgreSQL or TimescaleDB instance.
- Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied.
- Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
Flow: pfhub tag CLI Command Execution
pfhub tag create <name> [--user <username>] [--admin]- Resolves
--usertouser_idviarepo.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.
- Resolves
pfhub tag list [--inactive]- Calls
repo.list_tags_master(include_inactive=--inactive). - Renders Tag table (table format) or JSON array (--format json).
- Calls
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).
- Without
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).
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).
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(...).
- Without
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
- All portfolio mutation commands (
status,create,update,buy,sell,invest,transfer,reset,delete) accept a<name>positional argument and--user <username>option. _resolve_portfolio_id(repository, name, user)is called before any mutation: a. Callsget_user_by_username(user)→ raises ValueError if user not found. b. Callsget_portfolio_by_name_and_user(owner_user_id, name)→ raises ValueError if portfolio not found. c. Returnsportfolio.portfolio_id(UUID) for downstream use.createcommand takes<name>as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.update --new-name <new_name>renames the portfolio;--forceis required to confirm the rename.commentandtagsub-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 — Tag Management (POST/GET/PATCH /v1/tags)
- POST /v1/tags (create tag):
- DB token required; trade-write or admin permission.
- Env token → HTTP 403.
nameis stripped and lowercased;is_admin_tagset 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.
- 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}.
- 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 200TagItem.
Flow: v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)
- 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_portfolio_tag_id(asset_id, tag_id)→ HTTP 200{"tag_id": ..., "status": "applied"}.
- DB token required; admin permission (
- 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_portfolio_tag_id(asset_id, tag_id)→ HTTP 200{"tag_id": ..., "status": "removed"}.
- POST /v1/portfolios/{portfolio_id}/tags (apply tag to portfolio — owner or admin):
- DB token required; trade-write or admin permission.
repo.get(portfolio_id)→ ValueError → HTTP 404.- Caller must be portfolio owner or admin; else → HTTP 403.
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"}.
- DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} (remove tag from portfolio — owner or admin):
- DB token required; trade-write or admin permission.
repo.get(portfolio_id)→ ValueError → HTTP 404.- Caller must be portfolio owner or admin; else → HTTP 403.
repo.remove_portfolio_tag_id(portfolio_id, tag_id)→ HTTP 200{"tag_id": ..., "status": "removed"}.
Flow: v1 API — DB Token Authentication
- v1 endpoint receives Bearer token from Authorization header.
require_read/require_adminresolves the token: checks env tokens first, then DB lookup viaget_token_principal().- Env tokens → HTTP 403 (all /v1 endpoints require DB tokens).
AuthContextis populated withuser_id,token_type,permission,runtime_id, anddaemon_id.
Flow: v1 API — GET /v1/assets
- DB token required;
token_typedrives branching:service: daemon-id guard — ifauth.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)wherefilter_user_id = ?user_id param if provided, else auth.user_id.
- Optional
?status=<value>query param post-filters portfolios by status field. - Optional
?market=<code>query param filters byassets.marketsarray (SQL:%s = ANY(a.markets)). - For portfolios with
active_strategy_id,get_strategy_set()fetches config and JSON-parsesstrategy_params; failures fall back to{}. - Returns
AssetsResponsewithtotalandassets[]using field names:cash,quantity,broker_type,strategy_params. asset_idin each asset item equalsportfolio.portfolio_id(server-generated UUID).
Flow: Admin Asset Catalog CRUD — POST/PATCH /v1/assets
- Admin token required (
require_admin). POST /v1/assets:repo.insert_asset(symbol, markets, display_name, status)→ 201AssetCatalogResponse.PATCH /v1/assets/{asset_id}:repo.update_asset(asset_id, markets, display_name, status)→ 200AssetCatalogResponse.PATCH /v1/assets/{asset_id}/deactivate:repo.update_asset(asset_id, status="inactive")→ 200AssetCatalogResponse.- Non-admin callers receive HTTP 403.
Flow: v1 API — GET /v1/user_info
- Service-token auth required. Available to all service tokens including unbound (daemon_id=NULL); no daemon-id guard.
get_user()fetches user record byuser_id.list_broker_credentials()fetches all broker credential rows for the user.- For each credential, Fernet-decrypts
encrypted_payloadusingCREDENTIAL_ENCRYPTION_KEY; decryption failures log a warning and returncredential_payload: null. - Returns
UserInfoResponsewithtrading_enabled(fromis_active),disabled_reason,brokers[],runtime_id(null if unbound), anddaemon_id(null for legacy/unbound tokens — diagnostic, not an error).
Flow: v1 API — POST /v1/assets/{asset_id}/ledger/trades
- Service-token auth required with
trade-writepermission. - Validates asset existence via
repo.get(asset_id); HTTP 404 if missing. - 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"). - Calls
insert_ledger_trade()with idempotency key(asset_id, order_id, execution_id). - If new record: returns HTTP 201 with
status="new". - If duplicate key with identical payload: returns HTTP 200 with
status="duplicate". - If duplicate key with different payload: returns HTTP 409 with conflict detail.
Flow: v1 API — POST /auth/service-tokens
- Admin token auth required (
require_admindependency). - Validates request body:
name,username,permission,expires_in_days/no_expiry. - Calls
AuthService.issue_service_token()which creates a new DB token withruntime_id. - Returns HTTP 201 with
token(raw value),runtime_id, andauth_tokenmetadata.
Flow: Daemon Registry — Create and Bind Service Token
- Operator creates a daemon via
pfhub daemon create <daemon_slug> --user <username>(optionally--display-name <name>).AuthServiceis not involved; the repository creates theDaemonrecord directly withowner_user_idresolved fromusername.- Returns
daemon.id(server-generated UUID),daemon_slug,is_active=True.
- 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, andowner_user_idmatches the token's user.- Unknown or inactive daemon → ValueError ("존재하지 않거나 비활성").
create_auth_token()persists the token withdaemon_idset.
- Operator assigns an asset to the daemon via portfolio update (
managed_by_daemon_idcolumn set). GET /v1/assetswith the bound token returns only assets whereportfolios.managed_by_daemon_id = token.daemon_id.POST /v1/assets/{asset_id}/ledger/tradeswith the bound token passes daemon assignment authorization and records the trade.
Flow: Service-Token Rotate — daemon_id Invariant
- Operator calls
pfhub service-token rotate <name> --force. AuthService.rotate_service_token(name)delegates torepository.rotate_service_token(name).- Repository looks up the existing token by name; raises ValueError if not found.
- Old token is revoked (
revoked_at = NOW()). - New token row is created with:
- New raw token value (generate_token()), new
token_hash, newtoken_prefix. - New
runtime_id(uuid4) — per-token, changes on every rotate. - Same
daemon_idas the old token — stable across rotates (daemon_id invariant). - Same
user_id,name,permission,token_type,expires_at.
- New raw token value (generate_token()), new
- Returns raw token (shown once) and new
auth_tokenmetadata including newruntime_id. GET /v1/user_infocalled with the new token returns the samedaemon_idbut a newruntime_id.
Flow: Application Startup Validation
lifespanstartup hook runs_bootstrap_initial_user()if DB enabled and bootstrap credentials configured.- If
db_enabled=True, callssettings.require_credential_encryption_key(). - If
CREDENTIAL_ENCRYPTION_KEYis not configured, raisesRuntimeErrorand aborts startup.
Flow: Local DB Initialization and Validation
Scenario A — Fresh DB (DB 미존재 상태)
docker compose -f docker-compose.local-test.yml up -d— TimescaleDB 컨테이너 시작.- 컨테이너 준비 확인:
docker compose -f docker-compose.local-test.yml ps→portfolio-hub-timescaledb상태가healthy인지 확인. pfhub_DB_ENABLED=true pfhub init-db— 대상 DB 생성 + Alembicupgrade head실행.- 기대 출력:
database: portfolio_hub,created: True,schema_initialized: True,migration_applied: head - pass 기준: exit code 0, 위 필드 모두 출력
- fail 기준: connection refused, permission denied, Alembic revision 오류
- 기대 출력:
pfhub_DB_ENABLED=true pfhub health-check— DB ping 성공 확인.- 기대 출력:
db_enabled: True,repository: PostgresPortfolioRepository - pass 기준: exit code 0,
db_enabled: True
- 기대 출력:
- Smoke flow: 사용자 생성 → portfolio 생성 →
portfolio list --user <username>확인.- pass 기준: 생성된 portfolio가 list 출력에 포함됨
Scenario B — runtime-precreated schema (ensure_schema() 선행)
- DB 컨테이너 시작 (Scenario A 1~2 동일).
pfhub_DB_ENABLED=true uvicorn app.main:app— App 기동.ensure_schema()가 핵심 테이블 생성.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-checkexit code 0
- 내부 동작:
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로 처리된다.
경계 규칙
- 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로 추가됨. - Alembic만 소유 (ensure_schema()에 없음):
portfolios.market,auth_tokens.runtime_id,idx_auth_tokens_runtime_id_unique,broker_credentials,ledger_trades. ensure_schema()만 실행한 환경에서 v1 API 사용 불가. - 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. - 허용 실행 순서:
init-db선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시migrate-db후속 실행. - 금지:
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-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.