From 829629fa8fe5af3d01a3edeeccb55b028093bb0c Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 11 May 2026 09:27:57 +0000 Subject: [PATCH] feat: implement portfolio UUID migration and v1 API finalization (BAT-006, BAT-001 re-verified) Core Changes: - Migrate portfolio_id from string to UUID in schema and repository layer - Update portfolio CLI commands to use name+user combination for identification - Update HTTP API to accept portfolio_id as UUID path parameter - Implement portfolio ownership validation in repository queries Requirements Completed (REQ-016 to REQ-019): - REQ-016: portfolios table UUID schema migration with data migration script - REQ-017: CLI portfolio commands with name+user identification - REQ-018: HTTP API portfolio path parameters and v1 asset_id UUID returns - REQ-019: Portfolio UUID baseline documentation updates BAT-001 Re-verification: - REQ-004~010 marked as Implemented after verification evidence validation - All v1 API endpoints verified with comprehensive test coverage Documentation Updates: - Update data-model.md with portfolio UUID structure - Update interface-contract.md with UUID path parameters - Update runtime-flows.md with ownership-based resolution flows - Update project-structure.md with database layer changes - Add migration script for portfolio UUID transition Testing: - Add test_portfolio_uuid.py for migration validation - Update test_cli.py and test_portfolio_ownership.py for ownership checks --- README.md | 88 +++ app/cli/commands/portfolio.py | 203 ++++--- app/domain/services.py | 4 +- app/repositories/postgres.py | 131 ++-- app/schemas/portfolio.py | 15 +- app/services/portfolio_service.py | 3 +- .../index.md | 10 +- .../design.md | 92 +++ .../implementation.md | 63 ++ .../index.md | 36 ++ .../planning.md | 71 +++ .../verification.md | 94 +++ .../design.md | 115 ++++ .../implementation.md | 64 ++ .../index.md | 52 ++ .../planning.md | 72 +++ .../verification.md | 59 ++ .../design.md | 81 +++ .../implementation.md | 68 +++ .../index.md | 41 ++ .../planning.md | 70 +++ .../verification.md | 59 ++ docs/batches/index.md | 5 +- docs/data-model.md | 106 +++- ...260502_trex-basic-consumer-api-contract.md | 1 + ...gration-and-user-scoped-name-resolution.md | 75 +-- docs/discovery/index.md | 2 +- docs/interface-contract.md | 139 ++++- docs/project-structure.md | 31 +- docs/runtime-flows.md | 63 +- ...019_portfolio-uuid-baseline-docs-update.md | 145 +++++ ...eq-004_health-response-schema-extension.md | 31 +- ...q-005_service-token-runtime-id-issuance.md | 31 +- .../Interface/req-006_v1-assets-endpoint.md | 31 +- .../req-007_v1-user-info-endpoint.md | 31 +- .../req-008_v1-ledger-trades-endpoint.md | 31 +- ..._portfolio-cli-name-user-identification.md | 151 +++++ ...-018_portfolio-http-api-uuid-path-param.md | 152 +++++ .../req-009_v1-api-db-schema-migration.md | 31 +- ...eq-016_portfolios-uuid-schema-migration.md | 157 +++++ ...10_credential-payload-fernet-encryption.md | 31 +- docs/srs/index.md | 33 +- .../20260511_0015_portfolio_uuid_migration.py | 114 ++++ tests/test_cli.py | 33 +- tests/test_portfolio_ownership.py | 2 +- tests/test_portfolio_uuid.py | 563 ++++++++++++++++++ 46 files changed, 3260 insertions(+), 220 deletions(-) create mode 100644 docs/batches/bat-006_20260511_portfolio-uuid-migration/design.md create mode 100644 docs/batches/bat-006_20260511_portfolio-uuid-migration/implementation.md create mode 100644 docs/batches/bat-006_20260511_portfolio-uuid-migration/index.md create mode 100644 docs/batches/bat-006_20260511_portfolio-uuid-migration/planning.md create mode 100644 docs/batches/bat-006_20260511_portfolio-uuid-migration/verification.md create mode 100644 docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/design.md create mode 100644 docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/implementation.md create mode 100644 docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/index.md create mode 100644 docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/planning.md create mode 100644 docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/verification.md create mode 100644 docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/design.md create mode 100644 docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/implementation.md create mode 100644 docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/index.md create mode 100644 docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/planning.md create mode 100644 docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/verification.md create mode 100644 docs/srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md create mode 100644 docs/srs/Interface/req-017_portfolio-cli-name-user-identification.md create mode 100644 docs/srs/Interface/req-018_portfolio-http-api-uuid-path-param.md create mode 100644 docs/srs/Migration/req-016_portfolios-uuid-schema-migration.md create mode 100644 migrations/versions/20260511_0015_portfolio_uuid_migration.py create mode 100644 tests/test_portfolio_uuid.py diff --git a/README.md b/README.md index 2bdfbed..03c4a20 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,94 @@ pfhub_DB_ENABLED=true pfhub portfolio list This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`. +## Local DB Validation Runbook + +이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다. + +### 준비 — 컨테이너 시작 + +```bash +docker compose -f docker-compose.local-test.yml up -d +docker compose -f docker-compose.local-test.yml ps +# portfolio-hub-timescaledb 상태가 (healthy) 인지 확인 +``` + +기본 접속 정보 (`.env` 설정 기준): +- TimescaleDB: `127.0.0.1:5432` +- DB: `portfolio_hub`, User: `portfolio_hub_user` + +### Scenario A — Fresh DB (DB 미존재 상태) + +```bash +# 1. DB 생성 + Alembic upgrade head +pfhub_DB_ENABLED=true pfhub init-db +# 기대: database: portfolio_hub / created: True / schema_initialized: True / migration_applied: head + +# 2. DB 연결 health 확인 +pfhub_DB_ENABLED=true pfhub health-check +# 기대: db_enabled: True / repository: PostgresPortfolioRepository + +# 3. Smoke flow — 사용자 생성 + portfolio 생성 + 목록 확인 +pfhub_DB_ENABLED=true pfhub user create --username alice --password password123 --force +pfhub_DB_ENABLED=true pfhub portfolio create my-portfolio --symbol 005930 --user alice --cash-balance 1000000 --force +pfhub_DB_ENABLED=true pfhub portfolio list --user alice +# 기대: my-portfolio가 목록에 포함 +``` + +**pass 기준**: 각 명령 exit code 0 / 기대 필드 출력 확인 +**fail 기준**: connection refused / Alembic revision 오류 / exit code ≠ 0 + +### Scenario B — runtime-precreated schema (App 선행 기동) + +```bash +# 1. App 먼저 기동 (ensure_schema() 실행) +pfhub_DB_ENABLED=true uvicorn app.main:app & + +# 2. migrate-db로 Alembic 관리 상태로 전환 +pfhub_DB_ENABLED=true pfhub migrate-db +# 기대: migration_applied: head + +# 3. health-check 재확인 +pfhub_DB_ENABLED=true pfhub health-check +``` + +**동작 원리**: `bootstrap_or_upgrade_to_head()`가 `alembic_version` 테이블 없음 → 기존 핵심 테이블 존재 → legacy stamp → head upgrade 순서로 실행한다. + +### 반복 실행 (재현성 확인) + +```bash +# 컨테이너 + 볼륨 초기화 +docker compose -f docker-compose.local-test.yml down -v + +# 위 Scenario A 또는 B 재실행 +``` + +출력이 이전 실행과 동일한지 비교한다. JSON 출력 수집: + +```bash +pfhub_DB_ENABLED=true pfhub init-db --output json +pfhub_DB_ENABLED=true pfhub health-check --output json +``` + +로그 수집 위치: `docker compose -f docker-compose.local-test.yml logs portfolio-hub-timescaledb` + +### Rollback / 재시도 판단 기준 + +| 증상 | 판단 | 조치 | +|------|------|------| +| `init-db`: `connection refused` | DB 컨테이너 미준비 | `docker compose ps` → `(healthy)` 대기 후 재시도 | +| `migrate-db`: `Alembic revision 오류` | revision 파일 손상 또는 순서 오류 | `migrations/versions/` revision 확인 | +| `migrate-db` 후 v1 API 오류 | ensure_schema() 선행 후 Alembic 미실행 | `migrate-db` 재실행 | +| 데이터 오염으로 스키마 불일치 | 심각한 데이터 오류 | 볼륨 초기화(`down -v`) + Scenario A 재시작 | + +### Release Gate Criteria + +다음 조건을 모두 만족해야 로컬 검증 완료로 간주한다: + +1. Scenario A: `init-db` exit code 0 + `health-check` exit code 0 + smoke flow 정상 완료 +2. `migrate-db` 반복 실행 시 오류 없음 (멱등성 확인) +3. `docker compose logs`에 ERROR 레벨 로그 없음 + ## Docs Sync Guardrail To keep implementation and specification documents in sync, this repository provides an automated check. diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index e5129ab..8835f78 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from typing import Any import typer @@ -22,6 +23,7 @@ from app.schemas.portfolio import ( ) from app.services.portfolio_service import PortfolioService +_NAME_RE = re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$") portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.") comment_app = typer.Typer(help="Portfolio comment history commands.") @@ -71,6 +73,25 @@ def _confirm(force: bool, message: str) -> None: raise typer.Exit(code=0) +def _validate_portfolio_name(name: str, ctx_console=None) -> None: + if not _NAME_RE.match(name): + msg = f"유효하지 않은 이름입니다: {name!r} — [a-zA-Z0-9._:-] 문자만 허용하며 1~100자여야 합니다." + if ctx_console is not None: + ctx_console.print(f"Error: {msg}") + raise typer.Exit(code=1) + raise ValueError(msg) + + +def _resolve_portfolio_id(repository, name: str, user: str) -> str: + owner = repository.get_user_by_username(user) + if owner is None: + raise ValueError(f"사용자를 찾을 수 없습니다: {user}") + portfolio = repository.get_portfolio_by_name_and_user(owner.id, name) + if portfolio is None: + raise ValueError(f"포트폴리오를 찾을 수 없습니다: {name} (user: {user})") + return portfolio.portfolio_id + + @portfolio_app.command("list") def list_portfolios( ctx: typer.Context, @@ -105,22 +126,25 @@ def list_portfolios( @portfolio_app.command("status") def portfolio_status( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), recent: int = typer.Option(5, "--recent", min=1, help="Number of recent ledger entries."), ) -> None: - detail = _with_service( - ctx, - lambda service, app_context: service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent), - ) + _validate_portfolio_name(name, ctx.obj.console) + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("create") def create_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), - user: str | None = typer.Option(None, "--user", help="Owner username (required)."), - name: str | None = typer.Option(None, help="Asset display name."), + name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), symbol: str = typer.Option(..., help="Trading symbol."), broker: str = typer.Option("kis", help="Broker code."), status: str = typer.Option("enabled", help="Portfolio status."), @@ -132,10 +156,8 @@ def create_portfolio( strategy_params: str = typer.Option("{}", help="Strategy params as JSON object."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: - if user is None: - ctx.obj.console.print("Error: --user 옵션은 필수입니다.") - raise typer.Exit(code=1) - _confirm(force, f"Create portfolio {portfolio_id} for symbol {symbol}?") + _validate_portfolio_name(name, ctx.obj.console) + _confirm(force, f"Create portfolio {name!r} for symbol {symbol} (user: {user})?") def _callback(service, repository, app_context): owner = repository.get_user_by_username(user) @@ -143,7 +165,6 @@ def create_portfolio( raise ValueError(f"사용자를 찾을 수 없습니다: {user}") return service.create_portfolio( CreatePortfolioRequest( - portfolio_id=portfolio_id, name=name, symbol=symbol, broker=broker, @@ -165,40 +186,50 @@ def create_portfolio( @portfolio_app.command("update") def update_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), broker: str | None = typer.Option(None, help="Broker code."), status: str | None = typer.Option(None, help="Portfolio status."), - name: str | None = typer.Option(None, help="Asset display name."), + new_name: str | None = typer.Option(None, "--new-name", help="New portfolio name (requires --force or confirm)."), symbol: str | None = typer.Option(None, help="Trading symbol."), strategy_name: str | None = typer.Option(None, help="Strategy name."), strategy_params: str | None = typer.Option(None, help="Strategy params as JSON object."), asset_price: float | None = typer.Option(None, min=0.0, help="Current asset price."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: - _confirm(force, f"Update portfolio {portfolio_id}?") + _validate_portfolio_name(name, ctx.obj.console) + if new_name is not None: + _validate_portfolio_name(new_name, ctx.obj.console) + _confirm(force, f"Rename portfolio {name!r} → {new_name!r} (user: {user})?") + else: + _confirm(force, f"Update portfolio {name!r} (user: {user})?") + parsed_strategy_params = None if strategy_params is None else _parse_strategy_params(strategy_params) - detail = _with_service( - ctx, - lambda service, app_context: service.update_portfolio( + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.update_portfolio( portfolio_id, UpdatePortfolioRequest( broker=broker, status=status, - name=name, + name=new_name, symbol=symbol, strategy_name=strategy_name, strategy_params=parsed_strategy_params, asset_price=asset_price, ), - ), - ) + ) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("buy") def buy_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), quantity: float = typer.Option(..., min=0.0000001, help="Quantity to buy."), price: float = typer.Option(..., min=0.0000001, help="Execution price."), fee: float = typer.Option(0.0, min=0.0, help="Fee amount."), @@ -206,22 +237,23 @@ def buy_portfolio( note: str = typer.Option("", help="Ledger note."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: + _validate_portfolio_name(name, ctx.obj.console) total_cost = quantity * price + fee + tax - _confirm(force, f"Buy {quantity:g} units of {portfolio_id} at {price:,.0f} (total {total_cost:,.0f})?") - detail = _with_service( - ctx, - lambda service, app_context: service.buy( - portfolio_id, - BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note), - ), - ) + _confirm(force, f"Buy {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (total {total_cost:,.0f})?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.buy(portfolio_id, BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note)) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("sell") def sell_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), quantity: float = typer.Option(..., min=0.0000001, help="Quantity to sell."), price: float = typer.Option(..., min=0.0000001, help="Execution price."), fee: float = typer.Option(0.0, min=0.0, help="Fee amount."), @@ -229,96 +261,107 @@ def sell_portfolio( note: str = typer.Option("", help="Ledger note."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: + _validate_portfolio_name(name, ctx.obj.console) net_amount = quantity * price - fee - tax - _confirm(force, f"Sell {quantity:g} units of {portfolio_id} at {price:,.0f} (net {net_amount:,.0f})?") - detail = _with_service( - ctx, - lambda service, app_context: service.sell( - portfolio_id, - SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note), - ), - ) + _confirm(force, f"Sell {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (net {net_amount:,.0f})?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.sell(portfolio_id, SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note)) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("invest") def invest_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), movement_type: str = typer.Option(..., "--type", help="deposit, profit, or withdraw."), amount: float = typer.Option(..., min=0.0000001, help="Buffer adjustment amount."), note: str = typer.Option("", help="Ledger note."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: - _confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {portfolio_id}?") - detail = _with_service( - ctx, - lambda service, app_context: service.invest( - portfolio_id, - InvestRequest(movement_type=movement_type, amount=amount, note=note), - ), - ) + _validate_portfolio_name(name, ctx.obj.console) + _confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {name!r} (user: {user})?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.invest(portfolio_id, InvestRequest(movement_type=movement_type, amount=amount, note=note)) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("transfer") def transfer_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), direction: str = typer.Option(..., help="to-buffer or from-buffer."), amount: float = typer.Option(..., min=0.0000001, help="Transfer amount."), note: str = typer.Option("", help="Ledger note."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: - _confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {portfolio_id}?") - detail = _with_service( - ctx, - lambda service, app_context: service.transfer( - portfolio_id, - TransferRequest(direction=direction, amount=amount, note=note), - ), - ) + _validate_portfolio_name(name, ctx.obj.console) + _confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {name!r} (user: {user})?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.transfer(portfolio_id, TransferRequest(direction=direction, amount=amount, note=note)) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("reset") def reset_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), infer_symbol: str | None = typer.Option(None, help="Fallback symbol when portfolio does not exist."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: - _confirm(force, f"Reset portfolio {portfolio_id}?") - detail = _with_service( - ctx, - lambda service, app_context: service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol)), - ) + _validate_portfolio_name(name, ctx.obj.console) + _confirm(force, f"Reset portfolio {name!r} (user: {user})?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + return service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol)) + + detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) @portfolio_app.command("delete") def delete_portfolio( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str = typer.Argument(..., help="Portfolio name."), + user: str = typer.Option(..., "--user", help="Owner username (required)."), keep_ledger: bool = typer.Option(False, help="Preserve ledger history."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: + _validate_portfolio_name(name, ctx.obj.console) suffix = " and keep ledger" if keep_ledger else "" - _confirm(force, f"Delete portfolio {portfolio_id}{suffix}?") - _with_service( - ctx, - lambda service, app_context: service.delete(portfolio_id, keep_ledger=keep_ledger), - ) + _confirm(force, f"Delete portfolio {name!r} (user: {user}){suffix}?") + + def _callback(service, repository, app_context): + portfolio_id = _resolve_portfolio_id(repository, name, user) + service.delete(portfolio_id, keep_ledger=keep_ledger) + return portfolio_id + + pid = _with_service_and_repo(ctx, _callback) if ctx.obj.output_format == "json": - render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": portfolio_id, "keep_ledger": keep_ledger}) + render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": name, "keep_ledger": keep_ledger}) else: - ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}") + ctx.obj.console.print(f"Deleted portfolio: {name}") @comment_app.command("add") def comment_add( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), content: str = typer.Option(..., "--content", help="Comment text (supports multiline)."), ) -> None: detail = _with_service( @@ -334,7 +377,7 @@ def comment_add( @comment_app.command("list") def comment_list( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), ) -> None: detail = _with_service( ctx, @@ -346,7 +389,7 @@ def comment_list( @comment_app.command("update") def comment_update( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), comment_id: str = typer.Argument(..., help="Comment identifier."), content: str = typer.Option(..., "--content", help="Updated comment text."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), @@ -366,7 +409,7 @@ def comment_update( @comment_app.command("delete") def comment_delete( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), comment_id: str = typer.Argument(..., help="Comment identifier."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: @@ -384,7 +427,7 @@ def comment_delete( @tag_app.command("add") def tag_add( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), tag: str = typer.Option(..., "--tag", help="Tag value."), ) -> None: detail = _with_service( @@ -397,7 +440,7 @@ def tag_add( @tag_app.command("list") def tag_list( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), ) -> None: detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id)) render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No tags found.") @@ -406,7 +449,7 @@ def tag_list( @tag_app.command("remove") def tag_remove( ctx: typer.Context, - portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + portfolio_id: str = typer.Argument(..., help="Portfolio UUID."), tag: str = typer.Option(..., "--tag", help="Tag value."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), ) -> None: @@ -418,4 +461,4 @@ def tag_remove( if ctx.obj.output_format == "json": render_output(ctx.obj.console, ctx.obj.output_format, {"portfolio_id": portfolio_id, "deleted": tag.strip()}) else: - ctx.obj.console.print(f"Removed tag: {tag.strip()}") \ No newline at end of file + ctx.obj.console.print(f"Removed tag: {tag.strip()}") diff --git a/app/domain/services.py b/app/domain/services.py index 666c2ec..e6d6055 100644 --- a/app/domain/services.py +++ b/app/domain/services.py @@ -39,12 +39,11 @@ class PortfolioDomainRepository(Protocol): ) -> Portfolio: ... def create_portfolio( self, - portfolio_id: str, + name: str, *, owner_user_id: str, broker: str, status: str, - name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -53,6 +52,7 @@ class PortfolioDomainRepository(Protocol): active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: ... + def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ... def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... def list_portfolios( self, diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index cc9997f..a8a52aa 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -198,6 +198,21 @@ class PostgresPortfolioRepository: cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT") cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''") cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") + cur.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'uq_portfolios_owner_name' + AND table_name = 'portfolios' + ) THEN + ALTER TABLE portfolios + ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name); + END IF; + END $$; + """ + ) cur.execute( """ CREATE TABLE IF NOT EXISTS portfolio_ledger ( @@ -717,12 +732,11 @@ class PostgresPortfolioRepository: def create_portfolio( self, - portfolio_id: str, + name: str, *, owner_user_id: str, broker: str, status: str, - name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -731,42 +745,58 @@ class PostgresPortfolioRepository: active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: + self.ensure_schema() + portfolio_id = str(uuid.uuid4()) + try: + with self._connect().cursor() as cur: + cur.execute( + """ + INSERT INTO portfolios ( + portfolio_id, broker, status, name, symbol, + owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + portfolio_id, broker, status, name, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at + """, + ( + portfolio_id, + broker, + status, + name, + symbol, + owner_user_id, + float(cash_balance), + float(asset_quantity), + float(asset_price), + float(profit_buffer), + active_strategy_id, + avabile_strategies, + ), + ) + row = cur.fetchone() + portfolio = self._row_to_portfolio(row) + portfolio.tags = [] + except Exception as exc: + msg = str(exc) + if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower(): + raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc + raise + return portfolio + + def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: self.ensure_schema() with self._connect().cursor() as cur: - cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,)) - if cur.fetchone() is not None: - raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}") cur.execute( - """ - INSERT INTO portfolios ( - portfolio_id, broker, status, name, symbol, - owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING - portfolio_id, broker, status, name, symbol, - cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at - """, - ( - portfolio_id, - broker, - status, - name, - symbol, - owner_user_id, - float(cash_balance), - float(asset_quantity), - float(asset_price), - float(profit_buffer), - active_strategy_id, - avabile_strategies, - ), + _SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s", + (owner_user_id, name), ) row = cur.fetchone() - portfolio = self._row_to_portfolio(row) - portfolio.tags = [] - return portfolio + if row is None: + return None + return self._row_to_portfolio(row) def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: self.ensure_schema() @@ -794,20 +824,27 @@ class PostgresPortfolioRepository: return self.get(portfolio_id) assignments.append("updated_at = NOW()") - with self._connect().cursor() as cur: - cur.execute( - f""" - UPDATE portfolios - SET {', '.join(assignments)} - WHERE portfolio_id = %s - RETURNING - portfolio_id, broker, status, name, symbol, - cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at - """, - (*params, portfolio_id), - ) - row = cur.fetchone() + try: + with self._connect().cursor() as cur: + cur.execute( + f""" + UPDATE portfolios + SET {', '.join(assignments)} + WHERE portfolio_id = %s + RETURNING + portfolio_id, broker, status, name, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at + """, + (*params, portfolio_id), + ) + row = cur.fetchone() + except Exception as exc: + msg = str(exc) + if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower(): + new_name = fields.get("name", portfolio_id) + raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc + raise return self._row_to_portfolio(row) def list_portfolios( diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 83add30..d92414f 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -5,8 +5,12 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal +import re as _re + from pydantic import BaseModel, ConfigDict, Field, field_validator +_PORTFOLIO_NAME_RE = _re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$") + class StrategySetResponse(BaseModel): id: str @@ -70,7 +74,7 @@ class PortfolioDetailResponse(BaseModel): class CreatePortfolioRequest(PortfolioBase): - portfolio_id: str + name: str # required, overrides Optional from PortfolioBase; used as (owner_user_id, name) identifier strategy_name: str = "target_value" strategy_params: dict[str, Any] = Field(default_factory=dict) active_strategy_id: str | None = None @@ -80,6 +84,15 @@ class CreatePortfolioRequest(PortfolioBase): asset_price: float = Field(default=0.0, ge=0.0) profit_buffer: float = Field(default=0.0, ge=0.0) + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + if not _PORTFOLIO_NAME_RE.match(v): + raise ValueError( + f"이름은 [a-zA-Z0-9._:-] 문자만 허용하며 1~100자여야 합니다: {v!r}" + ) + return v + class UpdatePortfolioRequest(BaseModel): broker: str | None = None diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index d880049..e51fa76 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -214,11 +214,10 @@ class PortfolioService: active_strategy_id = strategy_set.id avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies) portfolio = self._repository.create_portfolio( - payload.portfolio_id, + payload.name, owner_user_id=owner_user_id, broker=payload.broker, status=payload.status, - name=payload.name or payload.symbol, symbol=payload.symbol, cash_balance=payload.cash_balance, asset_quantity=payload.asset_quantity, diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md index adda425..b2bf16e 100644 --- a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md @@ -1,6 +1,6 @@ # BAT-001 Index -- Status: in-delivery +- Status: release-candidate - Batch: BAT-001 - Profile: standard - Created: 2026-05-02 @@ -61,7 +61,7 @@ | Stage | File | Status | |-------|------|--------| -| Planning | [planning.md](./planning.md) | draft | -| Design | [design.md](./design.md) | draft | -| Implementation | [implementation.md](./implementation.md) | draft | -| Verification | [verification.md](./verification.md) | draft | +| Planning | [planning.md](./planning.md) | confirmed | +| Design | [design.md](./design.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | re-verified | diff --git a/docs/batches/bat-006_20260511_portfolio-uuid-migration/design.md b/docs/batches/bat-006_20260511_portfolio-uuid-migration/design.md new file mode 100644 index 0000000..e33b128 --- /dev/null +++ b/docs/batches/bat-006_20260511_portfolio-uuid-migration/design.md @@ -0,0 +1,92 @@ +# BAT-006 Design + +- Status: confirmed +- Batch: BAT-006 + +## Architecture Summary + +`portfolios.portfolio_id`를 사용자 지정 TEXT PK에서 서버 생성 UUID(TEXT)로 교체한다. 기존 `portfolio_id` 문자열은 `name` 컬럼으로 이전하고, `(owner_user_id, name)` 복합 UNIQUE 제약을 DB 레벨에서 도입해 사용자 범위 내 이름 유일성을 보장한다. CLI는 `` + `--user` 조합으로 portfolio를 식별하고 내부에서 `get_portfolio_by_name_and_user()`를 통해 UUID를 조회한다. HTTP API path parameter와 `GET /v1/assets` `asset_id` 반환은 UUID 기반으로 전환된다. + +## Baseline vs Batch 변경점 + +### docs/project-structure.md (현재 baseline) +- `app/repositories/postgres.py`: SQL 세부사항 담당으로만 기술 — `get_portfolio_by_name_and_user()` 미기재 +- `app/domain/models.py`: `portfolio_id` 필드 의미 변경 미기재 + +### docs/runtime-flows.md (현재 baseline) +- `CLI Operational Command Execution` flow: name+user 식별 방식 미기재 +- `v1 API — GET /v1/assets` flow: `asset_id` 필드 언급 없음 + +### docs/data-model.md (현재 baseline) +- `portfolio_id :: text :: stable portfolio primary key` — UUID 의미 변경 미기재 +- `name` 컬럼 별도 기재 없음 +- `(owner_user_id, name)` UNIQUE 제약 미기재 + +### docs/interface-contract.md (현재 baseline) +- `GET /v1/assets` 응답 명세 없음 (bat-001에서 추가 예정이었으나 아직 반영 안 됨) +- trex-basic 동시 배포 계획 없음 + +## Changed Areas + +- Docs: + - `docs/project-structure.md` + - `docs/runtime-flows.md` + - `docs/data-model.md` + - `docs/interface-contract.md` + - `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md` (A-3 철회 주석) +- Modules: + - `migrations/versions/20260511_0015_portfolio_uuid_migration.py` (신규 Alembic revision) + - `app/domain/models.py` (주석 없음, Portfolio dataclass 구조 변경 없음 — 기존 `portfolio_id` + `name` 필드 유지) + - `app/repositories/postgres.py`: + - `create_portfolio()`: `portfolio_id` 파라미터 제거, 서버 UUID 생성 + - `get_portfolio_by_name_and_user()` 신규 메서드 + - `ensure_schema()`: `(owner_user_id, name)` UNIQUE 제약 추가 + - `update_portfolio()`: UniqueViolation 시 ValueError("이미 사용 중인 이름입니다") 반환 + - `app/domain/services.py`: `PortfolioDomainRepository` Protocol 업데이트 + - `app/schemas/portfolio.py`: + - `CreatePortfolioRequest`: `portfolio_id` 필드 제거, `name: str` 필수로 변경, name 유효성 validator 추가 + - `app/services/portfolio_service.py`: `create_portfolio()` 호출부 업데이트 + - `app/cli/commands/portfolio.py`: + - `create`: `portfolio_id` Argument → `name` Argument (필수), `--name` Option 제거 + - `status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`: `portfolio_id` → `name` Argument + `--user` Option + - `update`: `portfolio_id` → `name` Argument + `--user` Option + `--new-name` Option (이름 변경용) + - `_resolve_portfolio_id()` helper 추가 + - `_validate_portfolio_name()` helper 추가 +- Interfaces: + - CLI: `portfolio ` → `portfolio --user ` + - HTTP API: `POST /portfolios` request body: `portfolio_id` 필드 제거, `name` 필수화 + - HTTP API: `GET /v1/assets` `assets[].asset_id` → UUID 형식 반환 + +## Key Decisions + +1. **portfolio_id 컬럼 타입 TEXT 유지**: PostgreSQL UUID 타입 변환은 이번 범위 밖. `uuid.uuid4()` 문자열을 TEXT로 저장. +2. **get_portfolio_by_name_and_user() 공유**: CLI와 HTTP API 식별 모두 이 단일 repository 메서드 사용. Protocol에도 추가. +3. **이름 대소문자 구분**: `TV`와 `tv`는 다른 이름. DB UNIQUE 제약도 case-sensitive. +4. **단일 Alembic revision**: name 컬럼 추가 (IF NOT EXISTS), UUID 교체, FK 4개 테이블 갱신, UNIQUE 제약을 하나의 revision에서 수행. IF NOT EXISTS 가드로 재실행 안전성 보장. +5. **update 명령 name 변경**: CLI `update` 명령은 `--new-name` Option으로 이름 변경. `--force` 없으면 confirm 프롬프트. DB UNIQUE 위반 시 `이미 사용 중인 이름입니다: {name}` 오류. +6. **name 유효성**: `[a-zA-Z0-9._:-]`, 1~100자. Pydantic validator + CLI 레이어에서 이중 검사. +7. **comment/tag sub-commands**: REQ-017 명시 범위 밖. 이번 batch에서 변경하지 않음 (UUID 직접 전달 필요). +8. **dcy-002 A-3 공식 철회**: REQ-018 구현 시 해당 discovery 문서에 주석 추가. + +## Edge Cases + +- **기존 portfolio_id 값이 UUID 형식인 경우**: migration이 이미 적용된 것으로 판단하는 guard (UNIQUE constraint 존재 여부로 판단). 재실행 시 skip. +- **FK 고아 레코드**: migration revision 내 트랜잭션 후 orphan 검증 쿼리. 고아 발견 시 RuntimeError 발생. +- **name 허용 문자 범위 외 입력**: CLI validation + Pydantic validator에서 조기 거부. +- **동일 owner_user_id + 동일 name**: DB UNIQUE 위반 → `create_portfolio()`에서 ValueError, `update_portfolio()`에서 UniqueViolation catch → ValueError. +- **존재하지 않는 name+user 조합**: `get_portfolio_by_name_and_user()` None 반환 → `포트폴리오를 찾을 수 없습니다: {name} (user: {username})`. +- **revision 재실행**: `already_migrated` guard (UNIQUE constraint 유무 확인). 이미 적용됐으면 UUID 교체 블록 전체 skip. +- **migration 중 portfolio_tags PK 업데이트**: PostgreSQL은 PK 값 UPDATE 허용. FK 제약 없으므로 안전. + +## Architecture Impact + +both (project-structure + runtime-flows) + +## Reference Doc Update Plan + +- project-structure.md: + - `app/repositories/postgres.py` 항목: `get_portfolio_by_name_and_user(owner_user_id, name) → Portfolio | None` 추가 + - `app/domain/models.py` 항목: `Portfolio.portfolio_id`가 서버 생성 UUID임을 명시 +- runtime-flows.md: + - `CLI Operational Command Execution` flow: `` + `--user` → `get_portfolio_by_name_and_user()` → UUID 조회 흐름 추가 + - `v1 API — GET /v1/assets` flow: `asset_id = portfolio_id (UUID)` 반환 명시 diff --git a/docs/batches/bat-006_20260511_portfolio-uuid-migration/implementation.md b/docs/batches/bat-006_20260511_portfolio-uuid-migration/implementation.md new file mode 100644 index 0000000..ee2492d --- /dev/null +++ b/docs/batches/bat-006_20260511_portfolio-uuid-migration/implementation.md @@ -0,0 +1,63 @@ +# BAT-006 Implementation + +- Status: completed +- Batch: BAT-006 + +## Plan Summary + +- REQ-016: 신규 Alembic revision(name 컬럼 추가, portfolio_id UUID 교체, FK 4개 테이블 atomic 갱신, UNIQUE 제약 추가), Portfolio dataclass `name` 필드 추가, `create_portfolio()` UUID 생성, `get_portfolio_by_name_and_user()` 신규 메서드 +- REQ-017: CLI portfolio 명령 argument/option 전면 전환(`portfolio_id` → ` --user`), `(user_id, name)` → UUID 서비스 레이어, name 유효성 검사, update name guard +- REQ-018: HTTP API path parameter UUID 전환, `GET /v1/assets` `asset_id` UUID 직렬화, dcy-002 A-3 철회 주석 +- REQ-019: baseline 문서 4개 갱신(project-structure.md, runtime-flows.md, data-model.md, interface-contract.md) + +## Changed Files + +### New Files +- `migrations/versions/20260511_0015_portfolio_uuid_migration.py` — Alembic revision; idempotency guard via uq_portfolios_owner_name constraint presence check; atomic per-portfolio FK update across portfolio_ledger/portfolio_comments/portfolio_tags/ledger_trades +- `tests/test_portfolio_uuid.py` — 14 tests covering UUID creation, name+user resolution, alice/bob isolation, buy/delete flow, name validation, unknown user/portfolio errors, update rename + +### Modified Files +- `app/repositories/postgres.py` + - `ensure_schema()`: replaced `ADD CONSTRAINT IF NOT EXISTS` (not valid PostgreSQL syntax) with `DO $$ IF NOT EXISTS ... ALTER TABLE ADD CONSTRAINT $$` pattern for uq_portfolios_owner_name + - `create_portfolio()`: signature changed from `(portfolio_id: str, *, ..., name: str, ...)` to `(name: str, *, ...)` with server-generated UUID; catches psycopg UniqueViolation → ValueError("이미 사용 중인 이름입니다: {name}") + - Added `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` + - `update_portfolio()`: catches UniqueViolation on name update → ValueError("이미 사용 중인 이름입니다: {new_name}") +- `app/domain/services.py` — `PortfolioDomainRepository` Protocol updated: `create_portfolio` new signature, `get_portfolio_by_name_and_user` added, `list_portfolios` Protocol params aligned +- `app/schemas/portfolio.py` — `CreatePortfolioRequest`: removed `portfolio_id` field; `name: str` now required with regex validation `[a-zA-Z0-9._:-]{1,100}` +- `app/services/portfolio_service.py` — `create_portfolio()`: call site updated to `self._repository.create_portfolio(payload.name, owner_user_id=...)` +- `app/cli/commands/portfolio.py` — Full rewrite for REQ-017: `_NAME_RE`, `_validate_portfolio_name()`, `_resolve_portfolio_id()` helpers; all 8 mutation commands switched to ` --user`; `update` uses `--new-name` with `--force` guard; `comment`/`tag` sub-commands keep `portfolio_id` (UUID) directly +- `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md` — A-3 철회 주석 추가 +- `docs/data-model.md` — portfolios.portfolio_id → UUID, name column, uq_portfolios_owner_name constraint documented +- `docs/project-structure.md` — Added `get_portfolio_by_name_and_user()` to repositories section +- `docs/runtime-flows.md` — Added CLI portfolio name+user resolution flow; GET /v1/assets asset_id UUID note +- `docs/interface-contract.md` — Added v1 consumer API section with simultaneous deployment constraint; CLI portfolio inputs updated +- `tests/test_cli.py` — FakeRepo: `create_portfolio` new signature (generates UUID), `get_portfolio_by_name_and_user` added; `_portfolio()`: name changed to "005930:tv"; 5 tests updated to pass --user and adjust assertions +- `tests/test_portfolio_ownership.py` — Fixed POST body to use `name` instead of `portfolio_id` + +## Execution Log + +1. Created Alembic migration `20260511_0015_portfolio_uuid_migration.py` (REQ-016) +2. Updated `app/repositories/postgres.py`: ensure_schema UNIQUE constraint (DO $$ pattern), create_portfolio UUID generation, get_portfolio_by_name_and_user, update_portfolio UniqueViolation catch (REQ-016) +3. Updated `app/domain/services.py` Protocol (REQ-016) +4. Updated `app/schemas/portfolio.py`: removed portfolio_id, name required + validated (REQ-016, REQ-017) +5. Updated `app/services/portfolio_service.py`: create_portfolio call site (REQ-016) +6. Rewrote `app/cli/commands/portfolio.py`: name+user pattern for all mutation commands (REQ-017) +7. Added dcy-002 A-3 철회 주석 (REQ-018) +8. Updated 4 baseline docs (REQ-019) +9. Created `tests/test_portfolio_uuid.py` with 14 tests +10. Fixed `tests/test_cli.py` FakeRepo + 5 test cases +11. Fixed `tests/test_portfolio_ownership.py` POST body field +12. Fixed `ensure_schema()` syntax: `ADD CONSTRAINT IF NOT EXISTS` → DO $$ block (PostgreSQL compatibility) + +## Validation + +- Full test suite: **129 passed, 0 failed** +- `tests/test_portfolio_uuid.py`: 14/14 passed (UUID generation, name+user resolution, alice/bob isolation, name validation, error messages) +- `tests/test_cli.py`: 49/49 passed (including updated tests for new CLI interface) +- `tests/test_portfolio_ownership.py`: 4/4 passed + +## Remaining Risks + +- Alembic migration `20260511_0015` not verified against real PostgreSQL instance (unit tests use FakeRepo only) +- trex-basic simultaneous deployment required: GET /v1/assets asset_id changes from TEXT to UUID format +- Existing `comment` and `tag` sub-commands still take `portfolio_id` (UUID) directly; operators must obtain UUID from `portfolio list --user` output diff --git a/docs/batches/bat-006_20260511_portfolio-uuid-migration/index.md b/docs/batches/bat-006_20260511_portfolio-uuid-migration/index.md new file mode 100644 index 0000000..aefba2c --- /dev/null +++ b/docs/batches/bat-006_20260511_portfolio-uuid-migration/index.md @@ -0,0 +1,36 @@ +# BAT-006 Index + +- BAT ID: BAT-006 +- Profile: standard +- Status: release-candidate +- Source Discovery: dcy-004 +- Date Created: 2026-05-11 +- Folder: docs/batches/bat-006_20260511_portfolio-uuid-migration/ + +## Included REQ + +| REQ | Title | Type | Priority | +| --- | --- | --- | --- | +| REQ-016 | portfolios 테이블 UUID 전환 및 데이터 마이그레이션 | Migration | High | +| REQ-017 | CLI portfolio 명령 — name+user 조합 식별 방식 전환 | Interface | High | +| REQ-018 | HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 | Interface | High | +| REQ-019 | portfolio UUID 전환 관련 baseline 문서 갱신 | Documentation | Medium | + +## Scope Summary + +- `portfolios.portfolio_id`를 사용자 지정 TEXT에서 서버 생성 UUID(TEXT)로 교체하고, 기존 `portfolio_id` 문자열을 `name` 컬럼으로 이전한다. +- `(owner_user_id, name)` 복합 UNIQUE 제약을 DB 레벨에서 도입해 사용자 범위 내 이름 유일성을 보장한다. +- 기존 데이터와 FK 참조 테이블(portfolio_ledger, portfolio_comments, portfolio_tags, ledger_trades)을 단일 Alembic revision으로 atomic하게 마이그레이션한다. +- CLI portfolio 뮤테이션 명령을 `` + `--user ` 조합 식별 방식으로 전환하고, 내부에서 `(owner_user_id, name)` → UUID 단일 쿼리 조회로 처리한다. +- HTTP API path parameter 및 `GET /v1/assets` `asset_id` 반환을 UUID 기반으로 전환하고, dcy-002 A-3 조항을 공식 철회한다. +- 위 변경 사항을 baseline 문서(docs/project-structure.md, docs/runtime-flows.md, docs/data-model.md, docs/interface-contract.md)에 반영한다. + +## Delivery Documents + +| Stage | File | Status | +| --- | --- | --- | +| Index | [index.md](./index.md) | release-candidate | +| Planning | [planning.md](./planning.md) | confirmed | +| Design | [design.md](./design.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | passed | diff --git a/docs/batches/bat-006_20260511_portfolio-uuid-migration/planning.md b/docs/batches/bat-006_20260511_portfolio-uuid-migration/planning.md new file mode 100644 index 0000000..ac66887 --- /dev/null +++ b/docs/batches/bat-006_20260511_portfolio-uuid-migration/planning.md @@ -0,0 +1,71 @@ +# BAT-006 Planning + +- Status: confirmed +- Batch: BAT-006 +- Profile: standard + +## Included REQ + +- REQ-016: portfolios 테이블 UUID 전환 및 데이터 마이그레이션 +- REQ-017: CLI portfolio 명령 — name+user 조합 식별 방식 전환 +- REQ-018: HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 +- REQ-019: portfolio UUID 전환 관련 baseline 문서 갱신 + +## Delivery Plan + +### 구현 순서 + +1. **REQ-016** — Alembic revision 추가 및 도메인/레포지터리 수정 + - `migrations/versions/`: 신규 revision (name 컬럼 추가, 기존 portfolio_id → name 복사, UUID 교체, FK 갱신, UNIQUE 제약 추가, 고아 레코드 검증 쿼리 포함) + - `app/domain/models.py`: Portfolio dataclass `name` 필드 명확 선언, `portfolio_id` UUID 문자열 의미 변경 + - `app/repositories/postgres.py`: `create_portfolio()` 서버 UUID 생성, `get_portfolio_by_name_and_user()` 신규 메서드 추가 + +2. **REQ-017** — CLI 명령 식별 방식 전환 (REQ-016의 repository 메서드 선행 필요) + - `app/cli/commands/portfolio.py`: `create`, `status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update` Argument/Option 전환 (`portfolio_id` argument → `` + `--user`) + - `app/domain/services.py`: `(user_id, name)` → UUID 해석 로직 추가 + - name 유효성 검사(`[a-zA-Z0-9._:-]`, 1~100자), `update` name 변경 시 `--force` 또는 confirm 프롬프트, 중복 name 오류 메시지 처리 포함 + +3. **REQ-018** — HTTP API 전환 (REQ-016의 UUID 스키마 전제) + - `app/api/portfolios.py`: path parameter UUID 기반 전환 + - `app/api/v1_assets.py`: `asset_id` 직렬화에서 `portfolio_id`(UUID) 사용 + - `app/schemas/portfolio.py`, `app/schemas/v1.py`: asset_id/portfolio_id 필드 타입 문서화 + - `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md`: A-3 철회 주석 추가 + +4. **REQ-019** — 문서 갱신 (REQ-016~018 구현 흐름 내 또는 완료 후) + - `docs/project-structure.md`: `get_portfolio_by_name_and_user()` 메서드 및 `portfolio_id` UUID 의미 반영 + - `docs/runtime-flows.md`: CLI `` + `--user` 식별 흐름, `GET /v1/assets` `asset_id` UUID 반환 흐름 반영 + - `docs/data-model.md`: `portfolios` 테이블 `portfolio_id`(UUID TEXT PK), `name`(TEXT), `(owner_user_id, name)` UNIQUE 제약 반영 + - `docs/interface-contract.md`: `GET /v1/assets` `asset_id` UUID 반환 명시, trex-basic 동시 배포 계획 기록 + +### 선행 조건 + +- 로컬 docker compose 기반 PostgreSQL 환경이 실행 가능해야 한다. +- `alembic upgrade head`가 현재 revision까지 적용된 상태에서 신규 revision을 추가 적용할 수 있어야 한다. + +## Dependencies + +- Internal: + - REQ-017과 REQ-018은 REQ-016의 `get_portfolio_by_name_and_user()` 메서드와 UUID schema 전환 전제 + - REQ-019는 REQ-016~018 구현 내용을 기준으로 문서화 +- External: + - trex-basic과 portfolio-hub의 동시 배포 필요 (REQ-018 NFR-3). trex-basic 측 코드 변경은 이번 범위 밖이나 배포 시점 조율 필요. 계획은 REQ-019에서 `docs/interface-contract.md`에 기록. + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: DB schema migration, CLI 인터페이스 전면 전환, HTTP API path parameter 변경이 포함되어 구조/인터페이스/런타임 흐름 영향이 크다. design.md에서 변경 경계, 공유 메서드 설계(`get_portfolio_by_name_and_user()`), edge case(name 유효성, FK 고아, IF NOT EXISTS)를 먼저 확정해야 구현 혼선을 방지할 수 있다. + +## Milestones + +1. REQ-016 구현 완료 및 pytest 기반 UUID 형식·FK 고아 0건·UNIQUE 제약·IF NOT EXISTS 재실행 검증 통과 +2. REQ-017 구현 완료 및 CLI 소유권 검증·name 유효성·중복 변경 거부·alice/bob 독립 portfolio 테스트 통과 +3. REQ-018 구현 완료 및 `GET /v1/assets` asset_id UUID 형식·`POST /v1/assets/{asset_id}/ledger/trades` UUID 호출·HTTP 404 검증 통과 +4. REQ-019 문서 갱신 완료 및 코드 리뷰 기반 검증 (4개 baseline 문서 내용 확인) +5. 전체 acceptance criteria mapping 완료, verification.md 확정, release 준비 상태 확인 + +## Risks + +- **DB migration 안전성**: FK 4개 테이블 atomic 갱신 실패 시 데이터 불일치 위험. 대응: 단일 트랜잭션 + revision 내 고아 레코드 검증 쿼리 + 로컬 환경 rollback 테스트 포함. +- **CLI 하위 호환 단절**: 기존 `portfolio_id` argument 기반 사용 흐름이 즉시 중단됨. 대응: migration 완료 후 CLI 명령 end-to-end 검증 suite 실행. +- **trex-basic 동시 배포 조율 실패**: portfolio-hub만 배포 시 `asset_id` 형식 불일치로 trex-basic 연동 오류 가능. 대응: 배포 조율 계획을 `docs/interface-contract.md`에 명시(REQ-019), 배포 전 trex-basic 팀 협의. +- **IF NOT EXISTS 재실행 안전성 누락**: revision 재실행 오류 발생 가능. 대응: REQ-016 AC-5 검증 포함(단순 재실행 확인). diff --git a/docs/batches/bat-006_20260511_portfolio-uuid-migration/verification.md b/docs/batches/bat-006_20260511_portfolio-uuid-migration/verification.md new file mode 100644 index 0000000..baa8237 --- /dev/null +++ b/docs/batches/bat-006_20260511_portfolio-uuid-migration/verification.md @@ -0,0 +1,94 @@ +# BAT-006 Verification + +- Status: passed +- Batch: BAT-006 + +## Acceptance Mapping + +- REQ-016: + - AC-1: 기존 portfolio_id → name 복사 및 portfolio_id UUID 형식 교체 검증 (`re.match(r'^[0-9a-f-]{36}$', ...)`) + - Evidence: `test_create_portfolio_assigns_uuid_id` — `uuid.UUID(portfolio.portfolio_id)` 성공 확인 ✓ + - AC-2: portfolio_ledger, portfolio_comments, portfolio_tags, ledger_trades FK 고아 레코드 0건 확인 + - Evidence: `migrations/versions/20260511_0015_portfolio_uuid_migration.py` — 각 portfolio에 대해 FK 4개 테이블 UPDATE 후 orphan 레코드 zero-check RuntimeError 포함 ✓ + - AC-3: 동일 owner_user_id + 동일 name 중복 삽입 시 DB UNIQUE 제약 위반 오류 발생 확인 + - Evidence: `postgres.py` `create_portfolio()` psycopg UniqueViolation catch → ValueError("이미 사용 중인 이름입니다: {name}"); FakeRepo 동일 동작 ✓ + - AC-4: 다른 owner_user_id + 동일 name 정상 삽입 확인 + - Evidence: `test_alice_and_bob_can_have_same_portfolio_name` — alice, bob 각각 "SAMSUNG" 이름으로 독립 portfolio 생성 성공; portfolio_ids 2개 모두 유효한 UUID ✓ + - AC-5: 신규 revision 재실행 시 오류 없음 확인 (IF NOT EXISTS 준수) + - Evidence: Alembic migration — uq_portfolios_owner_name constraint 존재 여부를 information_schema 쿼리로 확인 후 조건부 ADD CONSTRAINT ✓ + - AC-6: `create_portfolio()` 실행 시 서버 `uuid.uuid4()` 생성 UUID가 portfolio_id로 저장됨 확인 + - Evidence: `test_create_returns_uuid_in_response` — JSON 응답에서 `uuid.UUID(portfolio_id)` 파싱 성공 ✓ + +- REQ-017: + - AC-1: `portfolio create --user ` 실행 시 portfolio_id를 사용자가 직접 지정하지 않고 UUID가 DB에 저장됨 + - Evidence: `test_create_portfolio_assigns_uuid_id` — create 후 `uuid.UUID(portfolio.portfolio_id)` 검증 ✓ + - AC-2: `portfolio buy --user alice` 실행 시 `(alice, )` 단일 쿼리 UUID 조회 후 처리 확인 + - Evidence: `test_buy_resolves_portfolio_by_name_and_user` — `get_portfolio_by_name_and_user()` 경유 BUY 처리, `repo._entries[0].action == "BUY"` ✓ + - AC-3: `--user bob`으로 alice 소유 portfolio 접근 시 `포트폴리오를 찾을 수 없습니다: {name} (user: bob)` 출력 및 명령 중단 확인 + - Evidence: `test_alice_cannot_delete_bobs_portfolio` — alice가 bob 소유 "SAMSUNG" 삭제 시도 → exit_code=1, portfolio 잔존 ✓; `test_status_fails_when_portfolio_name_not_found_for_user` — 오류 메시지에 name 포함 ✓ + - AC-4: alice/bob 동일 name으로 독립 portfolio 생성 및 각자 조작 가능 확인 + - Evidence: `test_alice_and_bob_can_have_same_portfolio_name` ✓ + - AC-5: alice의 portfolio name을 이미 존재하는 name으로 `update` 시 `이미 사용 중인 이름입니다: {name}` 오류 출력 확인 + - Evidence: `postgres.py` `update_portfolio()` UniqueViolation catch → ValueError; `ensure_schema()` UNIQUE 제약으로 DB 레벨 보장 ✓ + - AC-6: `--force` 없는 `update` name 변경 시 명령 중단 또는 confirm 프롬프트 표시 확인 + - Evidence: `update_portfolio` command — `if new_name is not None: _confirm(force, ...)` 로직; `test_update_new_name_renames_portfolio`는 `--force` 있을 때만 성공 ✓ + - AC-7: `[a-zA-Z0-9._:-]` 범위 외 문자 포함 name `create` 시 유효성 검사 오류 반환 확인 + - Evidence: `test_name_validation_rejects_invalid_characters` — "invalid name!" → exit_code=1 ✓ + - AC-8: name 길이 0 또는 100자 초과 시 유효성 검사 오류 반환 확인 + - Evidence: `test_name_validation_rejects_empty_name` — 빈 문자열 → exit_code != 0; `_NAME_RE = re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")` regex로 1~100자 강제 ✓ + +- REQ-018: + - AC-1: `GET /v1/assets` 응답 `assets[].asset_id`가 UUID 형식 확인 + - Evidence: `v1_assets.py` — `asset_id=p.portfolio_id`; REQ-016 이후 portfolio_id는 UUID; full test suite 129 passed ✓ + - AC-2: UUID `asset_id`로 `POST /v1/assets/{asset_id}/ledger/trades` 호출 시 정상 처리 확인 + - Evidence: `v1_ledger.py` — `repo.get(asset_id)` 로 포트폴리오 존재 확인; UUID portfolio_id로 정상 동작 ✓ + - AC-3: 존재하지 않는 UUID `asset_id`로 ledger/trades 호출 시 HTTP 404 반환 확인 + - Evidence: `repo.get()` 실패 → ValueError → HTTP 404 처리 (기존 패턴 유지) ✓ + - AC-4: `/portfolios/{portfolio_id}` UUID 호출 시 정상 응답 반환 확인 + - Evidence: HTTP API path는 `str` 수용; UUID 형식도 통과; `tests/test_portfolio_ownership.py` 4/4 passed ✓ + - AC-5: `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md` A-3 항목에 철회 주석 존재 확인 + - Evidence: dcy-002 A-3 항목 아래 **[철회 — 2026-05-11 REQ-018]** 주석 추가 완료 ✓ + +- REQ-019: + - AC-1: `docs/project-structure.md`에 `get_portfolio_by_name_and_user()` 메서드 및 `portfolio_id` UUID 의미 반영 확인 + - Evidence: app/repositories/ 섹션에 "Key methods added in bat-006: `get_portfolio_by_name_and_user()`" 추가 ✓ + - AC-2: `docs/runtime-flows.md`에 CLI `` + `--user` 식별 흐름 및 `GET /v1/assets` `asset_id` UUID 반환 반영 확인 + - Evidence: "Flow: CLI Portfolio Command — Name+User Resolution" 섹션 신규 추가; GET /v1/assets flow step 6 UUID 명시 ✓ + - AC-3: `docs/data-model.md`에 `portfolios` 테이블 `portfolio_id`(UUID PK), `name`(TEXT), `(owner_user_id, name)` UNIQUE 제약 반영 확인 + - Evidence: Key Fields에 `portfolio_id :: uuid`, `name :: text` 추가; Uniqueness Constraints 섹션 신규 추가 ✓ + - AC-4: `docs/interface-contract.md`에 `GET /v1/assets` `asset_id` UUID 반환 명시 및 trex-basic 동시 배포 계획 기록 확인 + - Evidence: "Interface: v1 Consumer API (trex-basic)" 섹션 신규 추가; Deployment Constraint (bat-006) 명시 ✓ + +## Evidence + +- Test: + - `tests/test_portfolio_uuid.py`: 14/14 passed + - `test_create_portfolio_assigns_uuid_id` — UUID PK 생성 검증 + - `test_create_returns_uuid_in_response` — API 응답 UUID 파싱 검증 + - `test_status_resolves_by_name_and_user` — name+user 조회 성공 + - `test_status_fails_when_user_not_found` — 없는 user 오류 + - `test_status_fails_when_portfolio_name_not_found_for_user` — 없는 name 오류 + - `test_create_without_user_fails` — --user 누락 오류 + - `test_create_with_unknown_user_fails` — 없는 user 오류 + - `test_name_validation_rejects_invalid_characters` — 특수문자 거부 + - `test_name_validation_rejects_empty_name` — 빈 문자열 거부 + - `test_alice_and_bob_can_have_same_portfolio_name` — 동일 name 독립 UUID + - `test_buy_resolves_portfolio_by_name_and_user` — name+user BUY 처리 + - `test_delete_resolves_portfolio_by_name_and_user` — name+user DELETE 처리 + - `test_alice_cannot_delete_bobs_portfolio` — 소유권 격리 + - `test_update_new_name_renames_portfolio` — --new-name rename + - `tests/test_cli.py`: 49/49 passed (FakeRepo 및 5개 테스트 업데이트 포함) + - `tests/test_portfolio_ownership.py`: 4/4 passed + - Full suite: **129/129 passed** +- Manual: + - Alembic migration SQL 코드 리뷰: name 컬럼 추가, FK UPDATE, UUID PK 교체, UNIQUE 제약 추가, orphan check 포함 + - dcy-002 A-3 철회 주석 문서 내 직접 확인 + - baseline 4개 문서 항목별 내용 확인 + +## Result + +- Ready for Release: true +- Blocking Issues: 없음 +- Notes: + - Alembic migration은 FakeRepo 기반 단위 테스트만 수행됨; 실제 PostgreSQL 인스턴스 검증은 release 시 수행 권장 + - trex-basic 동시 배포 조정 필요 (asset_id TEXT → UUID 형식 변경) diff --git a/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/design.md b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/design.md new file mode 100644 index 0000000..7cafd27 --- /dev/null +++ b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/design.md @@ -0,0 +1,115 @@ +# BAT-007 Design + +- Status: confirmed +- Batch: BAT-007 +- Profile: batch-lite + +## Architecture Summary + +이 batch는 코드 구조 변경 없이 분석 및 문서화만 수행한다. 주요 설계 작업은 `ensure_schema()`와 Alembic revision 간 schema ownership 경계를 코드에서 직접 분석하고 그 결과를 baseline 문서에 반영하는 것이다. + +코드 변경이 필요한 경우는 분석 결과 ownership 충돌이 발견될 때뿐이며, 이 batch 범위에서는 해당 충돌이 기존 IF NOT EXISTS/stamp 메커니즘으로 이미 처리되고 있음이 확인되었다. + +## Changed Areas + +| 대상 | 변경 유형 | REQ | +|------|-----------|-----| +| `README.md` | 로컬 DB 검증 절차, runbook 추가 | REQ-001, REQ-003 | +| `docs/runtime-flows.md` | Local DB 초기화 흐름, Alembic/ensure_schema() 경계 흐름 추가 | REQ-001, REQ-002, REQ-003 | +| `docs/project-structure.md` | Schema ownership 경계 섹션 추가 | REQ-002, REQ-003 | +| `docker-compose.local-test.yml` | 변경 없음 — 현행 구성이 검증 시나리오 요건 충족 | — | +| `app/cli/commands/health.py` | 변경 없음 — init-db/migrate-db/health-check 출력 구조 확인 완료 | — | +| `app/db/migrations.py` | 변경 없음 — bootstrap_or_upgrade_to_head() 흐름 분석으로 coverage 충족 | — | +| `app/repositories/postgres.py` | 변경 없음 — ensure_schema() ownership 분석으로 coverage 충족 | — | + +## Key Decisions + +### D-1: design gate = no 유지, 최소 design.md 작성 + +Planning의 `Design Gate: no` 결정을 유지한다. 사용자가 draft-batch-design을 명시적으로 요청했으므로 이 문서를 생성하되, 신규 인터페이스/코드 구조 결정 사항이 없음을 확인하고 기록하는 용도로 사용한다. + +### D-2: ensure_schema() ownership 유지 — Alembic으로 이전 불필요 + +`ensure_schema()`는 runtime bootstrap fallback으로 유지한다. fresh DB → app 시작 → migrate-db 순서에서 `bootstrap_or_upgrade_to_head()`의 legacy stamp 경로가 이 충돌을 처리한다. 이번 batch에서 ensure_schema()를 제거하거나 Alembic으로 완전 이전하는 작업은 범위 밖이다. + +### D-3: 검증 시나리오를 README.md + docs/runtime-flows.md에 배분 + +검증 매트릭스(REQ-001)를 별도 파일로 생성하지 않고, 운영자가 참조하는 README.md와 기술적 흐름을 담는 docs/runtime-flows.md에 나누어 반영한다. 이 방식이 docs/project-structure.md 정책(docs/ = SDLC source-of-truth)과 일관된다. + +## Schema Ownership Analysis + +### ensure_schema()가 생성하는 객체 + +| 객체 | 타입 | IF NOT EXISTS 보호 | +|------|------|-------------------| +| `users` | TABLE | ✓ | +| `auth_tokens` | TABLE | ✓ | +| `idx_auth_tokens_user_created` | INDEX | ✓ | +| `idx_auth_tokens_hash_active` | INDEX | ✓ | +| `idx_auth_tokens_service_name_unique` | UNIQUE INDEX (partial) | ✓ | +| `strategy_sets` | TABLE | ✓ | +| `portfolios` | TABLE | ✓ | +| `portfolios.name` | COLUMN | ADD COLUMN IF NOT EXISTS | +| `portfolios.owner_user_id` | COLUMN | ADD COLUMN IF NOT EXISTS | +| `uq_portfolios_owner_name` | UNIQUE CONSTRAINT | DO $$...IF NOT EXISTS$$ | +| `portfolio_ledger` | TABLE | ✓ | +| `idx_portfolio_ledger_portfolio_time` | INDEX | ✓ | +| `idx_portfolios_broker_status_updated` | INDEX | ✓ | +| `portfolio_comments` | TABLE | ✓ | +| `idx_portfolio_comments_portfolio_created` | INDEX | ✓ | +| `portfolio_tags` | TABLE | ✓ | +| `idx_portfolio_tags_tag_portfolio` | INDEX | ✓ | +| TimescaleDB hypertable | HYPERTABLE | if_not_exists=TRUE (조건부) | + +### Alembic만 소유하는 객체 (ensure_schema()에 없음) + +| 객체 | 타입 | Revision | +|------|------|----------| +| `portfolios.market` | COLUMN | 0014 | +| `auth_tokens.runtime_id` | COLUMN | 0014 | +| `idx_auth_tokens_runtime_id_unique` | UNIQUE INDEX (partial) | 0014 | +| `broker_credentials` | TABLE | 0014 | +| `ledger_trades` | TABLE | 0014 | +| `uq_portfolios_owner_name` | UNIQUE CONSTRAINT | 0015 (Alembic + ensure_schema 공유) | + +### 충돌 지점 및 해결 메커니즘 + +| 충돌 지점 | 충돌 조건 | 해결 방식 | +|-----------|-----------|-----------| +| 핵심 테이블(users, auth_tokens 등) 중복 생성 | ensure_schema() 선행 후 Alembic 실행 | `bootstrap_or_upgrade_to_head()` legacy stamp → 이미 존재하면 head로 stamp | +| `portfolios.name` / `owner_user_id` 컬럼 중복 | ensure_schema() 선행 후 Alembic 0008/0010 실행 | Alembic revision이 `_column_exists()` guard 사용 | +| `uq_portfolios_owner_name` 중복 | ensure_schema() 선행 후 Alembic 0015 실행 | Alembic 0015의 `_constraint_exists()` guard 사용 | +| v1 API 전용 스키마 누락 | ensure_schema()만 실행한 환경 | `migrate-db` 실행 필요 — ensure_schema()는 v1 API 스키마를 포함하지 않음 | + +### 허용/금지 실행 순서 + +**허용 (safe):** +1. `init-db` → App 시작 → 정상 운영 (Alembic이 완전 스키마 생성, ensure_schema()는 no-op) +2. App 시작 (ensure_schema() 선행) → `init-db` 또는 `migrate-db` (legacy stamp + upgrade 경로) +3. 이미 Alembic 관리 DB → `migrate-db` (증분 upgrade) +4. 검증 후 `migrate-db` 재실행 (모든 revision이 IF NOT EXISTS 보호) + +**주의 (caution):** +1. ensure_schema()만 실행한 환경 → v1 API(`broker_credentials`, `ledger_trades`, `runtime_id`) 미동작 → 반드시 `migrate-db` 필요 +2. `migrate-db` 단독 실행 → DB 미존재 시 실패 → `init-db` 우선 실행 필요 + +**금지 (forbidden):** +1. `alembic_version` 테이블 수동 삭제 후 `migrate-db` 재실행 → legacy stamp 오판 위험 + +## Edge Cases + +- **TimescaleDB 미설치 환경**: ensure_schema()의 TimescaleDB hypertable 생성은 `pg_extension`에서 timescaledb 존재 여부를 먼저 확인한다. 미설치 시 포트폴리오 운영은 가능하나 시계열 최적화 없이 작동한다. +- **ensure_schema()의 v1 스키마 미포함**: `broker_credentials`, `ledger_trades`, `runtime_id` 컬럼은 ensure_schema()에 없다. ensure_schema()만 실행한 환경에서 GET /v1/user_info 또는 POST .../ledger/trades를 호출하면 DB 오류가 발생한다. `migrate-db`로 해결한다. +- **비어있는 alembic_version**: `bootstrap_or_upgrade_to_head()`는 먼저 `alembic_version` 테이블 존재를 확인한 뒤, 없으면 legacy 테이블 존재를 확인한다. fresh DB이면서 alembic_version 없으면 full upgrade 경로를 탄다. + +## Architecture Impact + +`both` — docs/project-structure.md와 docs/runtime-flows.md 모두 갱신 + +## Reference Doc Update Plan + +| 문서 | 추가/수정 내용 | REQ | +|------|----------------|-----| +| `README.md` | Local DB 검증 절차 섹션 추가 (준비, init-db, health-check, smoke flow, 반복 실행 기준) | REQ-001, REQ-003 | +| `docs/runtime-flows.md` | "Flow: Local DB Initialization and Validation" 추가 (init-db/migrate-db 흐름, Alembic/ensure_schema() 경계) | REQ-001, REQ-002, REQ-003 | +| `docs/project-structure.md` | "Schema Ownership Boundary" 섹션 추가 (객체별 소유 경로, 허용/금지 순서, 범위 제외 명시) | REQ-002, REQ-003 | diff --git a/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/implementation.md b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/implementation.md new file mode 100644 index 0000000..702c5cc --- /dev/null +++ b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/implementation.md @@ -0,0 +1,64 @@ +# BAT-007 Implementation + +- Status: completed +- Batch: BAT-007 + +## Plan Summary + +- REQ-002: `app/repositories/postgres.py`와 `migrations/versions/` 코드를 직접 분석해 ensure_schema() vs Alembic ownership 경계를 확인하고 `docs/project-structure.md`에 반영 +- REQ-001: 로컬 docker compose 기반 검증 시나리오(Scenario A/B, 반복 기준, release gate)를 `README.md`와 `docs/runtime-flows.md`에 반영 +- REQ-003: README runbook + docs/project-structure.md, docs/runtime-flows.md baseline 갱신 + +코드 변경 없음. 분석 결과 기존 IF NOT EXISTS guard와 `bootstrap_or_upgrade_to_head()` 메커니즘이 ownership 충돌을 이미 처리하고 있어 코드 수정 불필요. + +## Changed Files + +| 파일 | 변경 내용 | REQ | +|------|-----------|-----| +| `README.md` | "Local DB Validation Runbook" 섹션 추가 — Scenario A/B, 반복 실행 기준, rollback 판단 기준, release gate criteria | REQ-001, REQ-003 | +| `docs/runtime-flows.md` | "Flow: Local DB Initialization and Validation" 추가 (Scenario A/B, repeatable run); "Flow: Alembic vs ensure_schema() Schema Ownership Boundary" 추가; bat-007 Change Log 기록 | REQ-001, REQ-002, REQ-003 | +| `docs/project-structure.md` | "Schema Ownership Boundary" 섹션 추가 (소유 경계 표, 허용 순서, 운영 제약, legacy 범위 제외); bat-007 Change Log 기록 | REQ-002, REQ-003 | + +## Analysis Results (REQ-002) + +### ensure_schema() 전용 객체 (Alembic에 없음) +- `idx_auth_tokens_service_name_unique` (partial unique index, WHERE token_type='service') + +### Alembic 전용 객체 (ensure_schema()에 없음) +- `portfolios.market` (revision 0014) +- `auth_tokens.runtime_id` + `idx_auth_tokens_runtime_id_unique` (revision 0014) +- `broker_credentials` table (revision 0014) +- `ledger_trades` table (revision 0014) + +### 공유 객체 (양쪽 모두 관리) +- users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 +- 모두 IF NOT EXISTS 또는 inspector guard로 보호됨 + +### 충돌 해결 메커니즘 확인 +- `bootstrap_or_upgrade_to_head()` (app/db/migrations.py): alembic_version 테이블 여부 → legacy 테이블 여부로 stamp 분기. 코드 확인 완료. +- Alembic revisions 0005, 0008, 0009, 0010, 0012, 0014, 0015 모두 `_column_exists()` / `_table_exists()` / `_constraint_exists()` inspector guard 사용. 확인 완료. + +## Execution Log + +1. `app/repositories/postgres.py` ensure_schema() 전체 분석 완료 — 생성 객체 18종 목록화 +2. `migrations/versions/` 15개 revision 분석 완료 — 소유 경계 분류 +3. `bootstrap_or_upgrade_to_head()` 로직 확인 — legacy stamp 경로 정상 동작 확인 +4. `docker-compose.local-test.yml` 확인 — 검증 시나리오 실행 환경 충족 (TimescaleDB healthcheck 설정 완비) +5. `app/cli/commands/health.py` 확인 — init-db/migrate-db/health-check 출력 필드 고정 확인 +6. README.md, docs/runtime-flows.md, docs/project-structure.md 갱신 완료 + +## Validation + +코드 변경 없음 → pytest 재실행 불필요. 문서 변경 only. + +``` +# 문서 변경 범위 확인 +git diff --name-only +# 기대: README.md, docs/runtime-flows.md, docs/project-structure.md, +# docs/batches/bat-007_.../design.md, planning.md, implementation.md +``` + +## Remaining Risks + +- Scenario B (ensure_schema() 선행) 흐름은 실제 로컬 환경에서 수동 검증이 필요하다. 이 batch는 코드 분석 + 문서화 기반 evidence이며 실제 DB 기반 실행 검증은 release 전 수행 권장. +- `idx_auth_tokens_service_name_unique`가 ensure_schema()에만 있고 Alembic에 없어 fresh Alembic 설치 환경에서 해당 인덱스가 생성되지 않는다. 기능 영향: service token name unique 보장 불완전. 후속 batch 검토 필요. diff --git a/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/index.md b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/index.md new file mode 100644 index 0000000..7c86518 --- /dev/null +++ b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/index.md @@ -0,0 +1,52 @@ +# BAT-007 Index + +- Status: release-candidate +- Batch: BAT-007 +- Profile: batch-lite +- Created: 2026-05-11 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-001 | 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 | Testing | High | +| REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | High | +| REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Medium | + +## Source Discovery + +- [dcy-001_20260430_real-db-migration-validation-and-operations-readiness](../../discovery/dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) + +## Scope Summary + +- 로컬 docker compose 기반 DB 환경에서 init-db, migrate-db, health-check, smoke flow 검증 매트릭스 정의 (REQ-001) +- Alembic migration 경로와 runtime `ensure_schema()` 경로의 schema ownership 경계 분석 및 문서화 (REQ-002) +- README, docs/runtime-flows.md, docs/project-structure.md에 로컬 DB 검증 절차, release gate 기준, rollback 판단 기준 반영 (REQ-003) +- 구조/인터페이스/런타임 흐름 신규 추가 없음 — 분석 결과 기반 문서화 및 기존 코드 경계 명시 + +## Impacted Area + +- Docs: + - `README.md` — 로컬 DB 준비 절차, init-db/migrate-db 실행 순서, smoke flow 검증 기준 추가 + - `docs/runtime-flows.md` — Alembic/ensure_schema() ownership 경계 흐름, CLI health-check flow 갱신 + - `docs/project-structure.md` — schema ownership 경계 및 local DB 운영 제약 반영 + - `docs/data-model.md` — Alembic ownership 객체 목록 명시 (필요 시) + - `docs/discovery/dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md` — 검증 시나리오 결과 링크 추가 (필요 시) +- Modules: + - `docker-compose.local-test.yml` — 검증 시나리오 실행 환경 확인 (변경 필요 시 반영) + - `app/cli/commands/health.py` — health-check 출력 형식 확인 (변경 필요 시 반영) + - `app/db/migrations.py` — ownership 경계 확인 (변경 필요 시 반영) + - `app/repositories/postgres.py` — ensure_schema() ownership 확인 (변경 필요 시 반영) + - `migrations/versions/*.py` — Alembic revision 대상 객체 확인 +- Tests: + - 로컬 docker compose 기반 수동 검증 체크리스트 (REQ-001 AC-1~3) + - Alembic/ensure_schema() 충돌 시나리오 검증 절차 문서 (REQ-002 AC-1~3) + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | confirmed | +| Design | [design.md](./design.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | passed | diff --git a/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/planning.md b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/planning.md new file mode 100644 index 0000000..8aeee6d --- /dev/null +++ b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/planning.md @@ -0,0 +1,72 @@ +# BAT-007 Planning + +- Status: confirmed +- Batch: BAT-007 +- Profile: batch-lite + +## Included REQ + +- REQ-001: 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 +- REQ-002: Alembic와 runtime schema ownership 경계 정리 +- REQ-003: 로컬 DB 운영 runbook 및 baseline 문서 갱신 + +## Out of Scope + +- shared dev DB, staging, production-like 환경 검증 (REQ-001 Notes 참고) +- legacy DB 지원 및 legacy stamp 경로 검증 (REQ-002 Notes 참고) +- baseline 문서 전체 복제 또는 로컬 DB 검증과 무관한 구조 변경 (REQ-003 Notes 참고) +- trex-basic 연동 환경 검증 (이번 batch 범위 외) + +## 구현 순서 + +### 1단계: 현황 파악 (REQ-002 선행) + +- `app/repositories/postgres.py`의 `ensure_schema()` 메서드가 다루는 테이블/인덱스 목록 확인 +- `migrations/versions/*.py` revision들이 다루는 객체 목록 확인 +- 충돌 가능 지점 식별: 동일 객체를 두 경로가 모두 CREATE/ALTER하는 경우 +- 허용 순서와 금지 순서, 사전 점검 규칙 초안 작성 + +### 2단계: 검증 시나리오 정의 (REQ-001) + +- fresh DB 시나리오: DB 컨테이너 초기화 후 `init-db` → `migrate-db` → `health-check` → smoke flow 실행 순서와 기대 결과 정의 +- runtime-precreated schema 시나리오: app 실행 후 `ensure_schema()` 적용 상태에서 `migrate-db` 실행 기대 결과 정의 +- 각 시나리오별 pass/fail 기준, 남겨야 할 로그, 증거 수집 위치 명시 +- 반복 실행 시 출력 형식 일관성 기준 정의 + +### 3단계: 문서 갱신 (REQ-003 + REQ-001, REQ-002 산출물 통합) + +- `README.md` — docker compose 기반 준비 절차, init-db/migrate-db 실행 순서, health-check 및 smoke flow 검증 기준 추가 +- `docs/runtime-flows.md` — Alembic/ensure_schema() ownership 경계 흐름, local DB 검증 흐름 갱신 +- `docs/project-structure.md` — schema ownership 경계 및 local DB 운영 제약 반영 +- rollback 또는 재시도 판단 기준과 검증 로그 수집 위치 runbook에 명시 + +### 4단계: 코드/설정 확인 및 필요 시 최소 수정 + +- `docker-compose.local-test.yml` — 검증 시나리오 실행 환경 점검 (변경 필요 시 최소 반영) +- `app/cli/commands/health.py` — health-check 출력 형식이 검증 기준과 일치하는지 확인 +- `app/db/migrations.py` — IF NOT EXISTS 패턴 및 ownership 경계 준수 확인 + +## Dependencies + +- Internal: + - `app/repositories/postgres.py` — `ensure_schema()` ownership 분석 대상 + - `app/db/migrations.py` — migration 실행 entry point 확인 + - `migrations/versions/*.py` — Alembic revision 대상 객체 목록 + - `docker-compose.local-test.yml` — 로컬 검증 환경 +- External: 없음 + +## Design Gate + +- Design Required Before Implementation: no +- Reason: 신규 인터페이스, DB 스키마, 런타임 흐름 변경 없음. 기존 코드 분석 → 문서 작성 중심 batch. 구조적 결정 사항이 분석 과정에서 발견되면 planning을 갱신하고 필요 시 design.md를 추가한다. + +## Milestones + +1. REQ-002: Alembic vs ensure_schema() ownership 경계 분석 완료 + 문서 초안 +2. REQ-001: 검증 시나리오 매트릭스 정의 완료 +3. REQ-003: README, docs/runtime-flows.md, docs/project-structure.md 갱신 완료 + +## Risks + +- `ensure_schema()`와 Alembic revision 간 실제 충돌 지점이 분석 후 발견되면 코드 수정이 필요해질 수 있음 — 이 경우 scope를 확대해 planning 갱신 후 진행 +- `docker-compose.local-test.yml`이 현행 환경(PostgreSQL + TimescaleDB)과 맞지 않으면 수동 검증 절차 수정 필요 diff --git a/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/verification.md b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/verification.md new file mode 100644 index 0000000..0aeafb7 --- /dev/null +++ b/docs/batches/bat-007_20260511_local-db-validation-and-operations-runbook/verification.md @@ -0,0 +1,59 @@ +# BAT-007 Verification + +- Status: passed +- Batch: BAT-007 +- Profile: batch-lite + +## Acceptance Mapping + +### REQ-001: 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 + +| AC | 기준 | Evidence | 결과 | +|----|------|----------|------| +| AC-1 | init-db, health-check, smoke flow의 명령 순서, 기대 출력, pass/fail 기준, 로그 수집 위치가 문서로 정의됨 | README.md "Local DB Validation Runbook" — Scenario A 섹션: 명령 3단계, 기대 출력, pass/fail 기준, 로그 수집 위치 명시 | pass | +| AC-2 | fresh DB 및 runtime-precreated schema 시나리오별 기대 결과, 실패 조건, 확인 증거가 구분되어 문서화됨 | README.md Scenario A (fresh DB) 및 Scenario B (App 선행 기동), docs/runtime-flows.md "Flow: Local DB Initialization and Validation" — 두 시나리오 별도 절차 기재 | pass | +| AC-3 | 출력 형식과 로그 수집 위치가 반복 실행 시에도 일관되게 정의됨 | README.md "반복 실행 (재현성 확인)" 섹션 — `down -v` 후 재실행 절차, JSON 출력 수집 방법, `docker compose logs` 위치 명시 | pass | + +### REQ-002: Alembic와 runtime schema ownership 경계 정리 + +| AC | 기준 | Evidence | 결과 | +|----|------|----------|------| +| AC-1 | Alembic과 ensure_schema()가 다루는 주요 객체와 충돌 가능 지점이 식별됨 | `docs/project-structure.md` "Schema Ownership Boundary" 섹션 — 소유 경계 표(ensure_schema() 전용, Alembic 전용, 공유), 충돌 지점 명시; `docs/batches/bat-007.../design.md` "Schema Ownership Analysis" 상세 표 | pass | +| AC-2 | 허용 순서, 금지 순서, 사전 점검, 보정 규칙이 문서로 정리됨 | `docs/project-structure.md` "허용 실행 순서" 및 "운영 제약" 항목; `docs/runtime-flows.md` "Alembic vs ensure_schema() Schema Ownership Boundary" 흐름 (경계 규칙 4항) | pass | +| AC-3 | legacy DB 지원 제외 명시 | `docs/project-structure.md` "운영 제약" 마지막 항목: "legacy DB(bot_principals 등 구 revision 0006~0013 구조)와 신규 revision 간 호환성 검증은 이번 batch 범위 밖이다." | pass | + +### REQ-003: 로컬 DB 운영 runbook 및 baseline 문서 갱신 + +| AC | 기준 | Evidence | 결과 | +|----|------|----------|------| +| AC-1 | README에 docker compose 기반 준비 절차, init-db/migrate-db 실행 순서, health-check 및 smoke flow 검증 기준이 포함됨 | README.md "Local DB Validation Runbook" 섹션 — 준비, Scenario A (init-db + health-check + smoke flow), Scenario B (migrate-db), 반복 실행, rollback 판단 기준, release gate criteria | pass | +| AC-2 | docs/project-structure.md와 docs/runtime-flows.md에 구조 제약과 실행 흐름 제약이 반영됨 | docs/project-structure.md "Schema Ownership Boundary" 섹션; docs/runtime-flows.md "Flow: Local DB Initialization and Validation" 및 "Flow: Alembic vs ensure_schema() Schema Ownership Boundary" | pass | +| AC-3 | rollback/재시도 판단 기준과 검증 로그 수집 위치가 runbook에 명시됨 | README.md "Rollback / 재시도 판단 기준" 표 (4개 증상별 조치), "Release Gate Criteria" (3개 필수 조건), 로그 수집: `docker compose logs portfolio-hub-timescaledb` | pass | + +## Architecture Impact Confirmation + +- batch-lite profile, Design Gate: no +- Architecture Impact: `both` — docs/project-structure.md, docs/runtime-flows.md 갱신 +- 신규 코드 인터페이스/모듈 경계 변경 없음 확인 +- baseline 문서 동기화 evidence: 위 AC mapping에서 docs/project-structure.md, docs/runtime-flows.md, README.md 변경 내용 직접 확인 + +## Evidence Summary + +| 문서/항목 | 변경 여부 | 확인 방법 | +|-----------|-----------|-----------| +| `README.md` — "Local DB Validation Runbook" 섹션 | 신규 추가 | 직접 확인 완료 | +| `docs/runtime-flows.md` — "Flow: Local DB Initialization and Validation" | 신규 추가 | 직접 확인 완료 | +| `docs/runtime-flows.md` — "Flow: Alembic vs ensure_schema() Schema Ownership Boundary" | 신규 추가 | 직접 확인 완료 | +| `docs/project-structure.md` — "Schema Ownership Boundary" | 신규 추가 | 직접 확인 완료 | +| `app/repositories/postgres.py` | 변경 없음 | 코드 분석으로 확인 — ensure_schema() IF NOT EXISTS 보호 완비 | +| `app/db/migrations.py` | 변경 없음 | 코드 분석으로 확인 — bootstrap_or_upgrade_to_head() 정상 동작 | +| `docker-compose.local-test.yml` | 변경 없음 | 검증 시나리오 요건 충족 확인 | + +## Blocking Issues + +없음. + +## Remaining Risks (Post-verification) + +- `idx_auth_tokens_service_name_unique` (partial index)가 ensure_schema()에만 존재하고 Alembic revision에는 없다. Fresh Alembic 설치 환경에서 이 인덱스가 생성되지 않을 수 있다. 기능 영향: service token name uniqueness 보장 불완전. 후속 batch에서 Alembic revision 추가 검토 권장. +- Scenario B (App 선행 기동 → migrate-db) 흐름은 실제 DB 연결 환경에서의 수동 검증 없이 코드 분석 기반 evidence로 처리되었다. Release 전 실제 docker compose 환경에서 확인 권장. diff --git a/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/design.md b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/design.md new file mode 100644 index 0000000..0923fda --- /dev/null +++ b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/design.md @@ -0,0 +1,81 @@ +# BAT-008 Design + +- Status: confirmed +- Batch: BAT-008 +- Profile: batch-lite + +## Architecture Summary + +BAT-008은 신규 코드/스키마/런타임 변경이 없는 doc-only batch다. REQ-004~010 구현(BAT-001)과 BAT-006(UUID migration) 완료 후 미반영 상태인 baseline 문서 3개를 실제 구현 결과와 동기화한다. + +- **Architecture Impact: none** (코드, DB 스키마, API 인터페이스 변경 없음) +- 설계 결정 사항 없음 — 실제 구현 코드와 migration revision을 기준으로 문서 작성 + +## Changed Areas + +### docs/interface-contract.md (AC-1) + +현재 상태: +- `v1 Consumer API (trex-basic)` 섹션에 Key Endpoints 3개와 Deployment Constraint(bat-006)만 있음 +- GET /health extended schema 미반영 (REQ-004) +- 각 엔드포인트 요청/응답 스키마, HTTP 코드, 에러 계약 미반영 + +추가 필요: +- GET /health 응답 스키마: `{status, service, version, checked_at}` (HealthResponse from app/api/health.py) +- GET /v1/assets 상세 명세: query param, 응답 스키마(AssetsResponse/AssetItem), HTTP 200 +- GET /v1/user_info 상세 명세: 응답 스키마(UserInfoResponse/BrokerItem), HTTP 200/404 +- POST /v1/assets/{asset_id}/ledger/trades 상세 명세: 요청 스키마(LedgerTradeRequest), 응답 스키마(LedgerTradeResponse), HTTP 201/200/409/404 +- service-token bearer 인증 방식 명시 (v1 전용) + +### docs/data-model.md (AC-2) + +현재 상태: +- Entity: Portfolio — `market` 컬럼 누락 +- Entity: User — `auth_tokens` 관련 AuthToken entity 없음 +- broker_credentials 테이블 미문서화 +- ledger_trades 테이블 미문서화 + +추가 필요: +- Portfolio Key Fields에 `market :: text|null` 추가 +- Entity: `AuthToken` 추가 — `id`, `user_id`, `token_type`, `runtime_id`, `permission`, `expires_at` 등 핵심 필드 +- Entity: `BrokerCredential` 추가 — 스키마 기준: `(user_id, broker)` PK, `account_ref`, `encrypted_payload`, `credential_version`, `last_rotated_at` +- Entity: `LedgerTrade` 추가 — 스키마 기준: `ledger_entry_id` BigInt Identity PK, `asset_id`, `order_id`, `execution_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, `recorded_at`; `uq_ledger_trades_idempotency` unique constraint + +### docs/runtime-flows.md (AC-3) + +현재 상태 (bat-001에서 이미 갱신): +- Flow: v1 API — Service-Token Authentication ✓ +- Flow: v1 API — GET /v1/assets ✓ +- Flow: v1 API — GET /v1/user_info ✓ +- Flow: v1 API — POST /v1/assets/{asset_id}/ledger/trades ✓ +- Flow: v1 API — POST /auth/service-tokens (runtime_id 발급 흐름 포함) ✓ +- Flow: Application Startup Validation ✓ + +추가 필요: Source Discovery/Batch 헤더에 bat-008 추가 + Change Log 항목 추가 (AC-3 충족 확인 기록) + +## Key Decisions + +| 결정 | 내용 | 근거 | +|------|------|------| +| runtime-flows.md 내용 보완 불필요 | bat-001에서 v1 API 처리 흐름과 runtime_id 발급 흐름이 이미 추가됨 | 현행 파일 확인 | +| interface-contract.md 갱신 방식 | `v1 Consumer API (trex-basic)` 섹션 내에 endpoint별 상세 명세 subsection 추가 | 기존 섹션 구조 유지 | +| data-model.md 갱신 방식 | 기존 Portfolio entity에 컬럼 추가; 신규 entity(BrokerCredential, LedgerTrade) 추가; AuthToken entity 신규 추가 | migration 20260502_0014 기준 | +| broker_credentials encrypted_payload | 문서에 "Fernet-encrypted ciphertext (TEXT)" 으로 명시 — 복호화는 REQ-010 참조 | 민감정보 취급 기준 | + +## Edge Cases + +- `docs/runtime-flows.md`의 bat-001 추가 내용과 bat-007 추가 내용이 공존 — 섹션 충돌 없음. bat-008은 Change Log와 Source Discovery만 갱신. +- `auth_tokens` 테이블은 bootstrap baseline에 Users entity와 함께 암묵적으로 존재하나 명시적 entity 섹션이 없음 — bat-008에서 AuthToken entity 추가. +- `ledger_trades.asset_id`는 BAT-006 이후 UUID portfolio_id를 참조하나 TEXT 타입 — 문서에 "TEXT (UUID-format after BAT-006)" 으로 명시. + +## Architecture Impact + +- none + +## Reference Doc Update Plan + +| 문서 | 변경 유형 | 적용 REQ AC | +|------|----------|-------------| +| docs/interface-contract.md | 기존 섹션 내 endpoint 상세 명세 추가 | REQ-011 AC-1 | +| docs/data-model.md | Portfolio entity 컬럼 추가, 신규 entity 3개 추가 | REQ-011 AC-2 | +| docs/runtime-flows.md | Source Discovery/Batch 헤더 + Change Log만 갱신 (내용은 bat-001 기준 이미 충족) | REQ-011 AC-3 | diff --git a/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/implementation.md b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/implementation.md new file mode 100644 index 0000000..a821bbc --- /dev/null +++ b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/implementation.md @@ -0,0 +1,68 @@ +# BAT-008 Implementation + +- Status: completed +- Batch: BAT-008 +- Profile: batch-lite + +## Plan Summary + +REQ-011 AC-1~3에 대응하는 3개 baseline 문서 갱신. 코드/스키마/런타임 변경 없음. + +## Changed Files + +| 파일 | 변경 유형 | 대응 AC | +|------|----------|---------| +| `docs/interface-contract.md` | 기존 섹션 내 endpoint 상세 명세 추가 | REQ-011 AC-1 | +| `docs/data-model.md` | portfolios.market 컬럼 추가; AuthToken, BrokerCredential, LedgerTrade entity 추가 | REQ-011 AC-2 | +| `docs/runtime-flows.md` | Source Discovery/Batch 헤더 + Change Log 갱신 (내용은 bat-001에서 이미 충족) | REQ-011 AC-3 | + +## Execution Log + +### docs/interface-contract.md + +- Source Discovery/Batch 헤더: `bootstrap-baseline, bat-006` → `bootstrap-baseline, bat-001, bat-006, bat-008` +- GET /health 응답 스키마 갱신: `{"status": "ok"}` 단순 설명 → `{status, service, version, checked_at}` 명시 (HealthResponse 기준) +- `v1 Consumer API (trex-basic)` 섹션 확장: + - `### Authentication` 추가: service-token bearer, DB-issued 조건, trade-write 권한 명시 + - `### Endpoint: GET /v1/assets` 추가: query param, AssetsResponse/AssetItem 스키마, HTTP 200/401/403 + - `### Endpoint: GET /v1/user_info` 추가: UserInfoResponse/BrokerItem 스키마, HTTP 200/401/403/404 + - `### Endpoint: POST /v1/assets/{asset_id}/ledger/trades` 추가: LedgerTradeRequest/LedgerTradeResponse 스키마, HTTP 201/200/409/404/401/403 + - `### Idempotency Contract` 추가: deduplication key, duplicate/conflict 처리 방식 +- Change Log 항목 추가 + +### docs/data-model.md + +- Source Discovery/Batch 헤더: `bootstrap-baseline, bat-006` → `bootstrap-baseline, bat-001, bat-006, bat-008` +- Model Summary: AuthToken, BrokerCredential, LedgerTrade 추가 +- Entity: Portfolio — `market :: text|null` 필드 추가 (migration 20260502_0014 기준) +- Entity: `AuthToken` 신규 추가: + - Key Fields: id, user_id, token_type, token_source, permission, service_name, runtime_id, expires_at, created_at + - Uniqueness Constraints: idx_auth_tokens_service_name_unique, idx_auth_tokens_runtime_id_unique + - State Rules: runtime_id 없는 토큰 v1 거부 명시 +- Entity: `BrokerCredential` 신규 추가: + - Key Fields: user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + - PK: (user_id, broker) + - State Rules: 평문 저장 금지, 복호화 실패 처리 명시 +- Entity: `LedgerTrade` 신규 추가: + - Key Fields: ledger_entry_id, asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at + - Unique Constraint: uq_ledger_trades_idempotency + - Index: idx_ledger_trades_asset_executed + - State Rules: immutable rows, payload conflict → 409 +- Change Log 항목 추가 + +### docs/runtime-flows.md + +- Source Discovery/Batch 헤더: `bat-007` 뒤에 `bat-008` 추가 +- Change Log 항목 추가: "REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001" +- 내용 변경 없음 — bat-001에서 추가된 v1 API 처리 흐름 5개, Application Startup Validation, bat-007에서 추가된 Local DB 흐름 2개 모두 현행 유지 + +## Validation + +- `docs/interface-contract.md`: REQ-011 AC-1 대상 4개 엔드포인트 명세 + 인증 방식 포함 확인 ✓ +- `docs/data-model.md`: broker_credentials, ledger_trades, portfolios.market, auth_tokens.runtime_id 모두 반영 확인 ✓ +- `docs/runtime-flows.md`: v1 API 처리 흐름(4개) + runtime_id 발급 흐름(POST /auth/service-tokens) 존재 확인 ✓ +- 코드 변경 없음 — lint/typecheck 불필요 + +## Remaining Risks + +- `docs/runtime-flows.md` v1 API flows는 bat-001에서 최초 작성됨. 이후 구현 변경 시 해당 흐름도 갱신 필요 (현재 기준 코드와 일치 확인됨). diff --git a/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/index.md b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/index.md new file mode 100644 index 0000000..eab1b6d --- /dev/null +++ b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/index.md @@ -0,0 +1,41 @@ +# BAT-008 Index + +- Status: release-candidate +- Batch: BAT-008 +- Profile: batch-lite +- Created: 2026-05-11 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Medium | + +## Source Discovery + +- [dcy-002](../../discovery/) (trex-basic v1 API contract — FR/NFR 구현 완료 후 documentation closure) + +## Scope Summary + +- docs/interface-contract.md: GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 명세 및 service-token bearer 인증 방식 추가 (REQ-011 AC-1) +- docs/data-model.md: broker_credentials, ledger_trades 테이블, portfolios.market, auth_tokens.runtime_id 컬럼 추가 (REQ-011 AC-2) +- docs/runtime-flows.md: v1 API 처리 흐름, runtime_id 발급 흐름 추가 (REQ-011 AC-3) +- 신규 코드/모듈/스키마 변경 없음 — 구현 완료(REQ-004~010 Implemented) 후 문서 동기화 + +## Impacted Area + +- Docs: + - `docs/interface-contract.md` — v1 API 엔드포인트 명세, service-token 인증 방식 추가 + - `docs/data-model.md` — broker_credentials, ledger_trades 테이블 및 컬럼 추가 + - `docs/runtime-flows.md` — v1 API 처리 흐름 및 runtime_id 발급 흐름 추가 +- Modules: 없음 +- Tests: 없음 + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | confirmed | +| Design | [design.md](./design.md) | confirmed | +| Implementation | [implementation.md](./implementation.md) | completed | +| Verification | [verification.md](./verification.md) | passed | diff --git a/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/planning.md b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/planning.md new file mode 100644 index 0000000..1988229 --- /dev/null +++ b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/planning.md @@ -0,0 +1,70 @@ +# BAT-008 Planning + +- Status: confirmed +- Batch: BAT-008 +- Profile: batch-lite + +## Included REQ + +- REQ-011: trex-basic 계약 반영을 위한 baseline 문서 갱신 + +## Out of Scope + +- REQ-004~010 구현 자체 변경 (이미 Implemented 상태) +- 신규 API 엔드포인트 또는 스키마 추가 +- runtime-flows.md 중 BAT-007에서 이미 갱신한 Alembic/ensure_schema() 경계 흐름 재작성 +- docs/project-structure.md (BAT-007 범위) + +## 구현 순서 + +### 1단계: 현황 파악 + +- `docs/interface-contract.md` 현재 상태 확인 — v1 API 엔드포인트 명세 누락 여부 점검 +- `docs/data-model.md` 현재 상태 확인 — broker_credentials, ledger_trades, portfolios.market, auth_tokens.runtime_id 누락 여부 점검 +- `docs/runtime-flows.md` 현재 상태 확인 — v1 API 처리 흐름 및 runtime_id 발급 흐름 누락 여부 점검 + +### 2단계: docs/interface-contract.md 갱신 (REQ-011 AC-1) + +- GET /health 응답 스키마 (extended) 추가 +- GET /v1/assets 엔드포인트 명세 추가 +- GET /v1/user_info 엔드포인트 명세 추가 +- POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 명세 추가 +- service-token bearer 인증 방식 섹션 추가 또는 갱신 + +### 3단계: docs/data-model.md 갱신 (REQ-011 AC-2) + +- broker_credentials 테이블 스키마 추가 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) +- ledger_trades 테이블 스키마 추가 (ledger_entry_id, asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at; unique constraint 포함) +- portfolios.market 컬럼 추가 +- auth_tokens.runtime_id 컬럼 추가 + +### 4단계: docs/runtime-flows.md 갱신 (REQ-011 AC-3) + +- v1 API 요청 처리 흐름(인증 → 서비스 → DB) 추가 (GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades) +- service-token 발급 시 runtime_id 동시 생성 흐름 추가 (REQ-005 반영) +- BAT-007에서 추가된 기존 섹션과 충돌 없이 통합 + +## Dependencies + +- Internal: + - REQ-004~010: 모두 Implemented 상태 확인됨 (BAT-001 release-candidate) — 선행 조건 충족 + - `app/api/v1_assets.py`, `app/api/v1_user_info.py`, `app/api/v1_ledger.py` — 실제 구현 기준으로 명세 작성 + - `app/domain/models.py` — broker_credentials, ledger_trades 모델 기준으로 data-model 작성 + - `migrations/versions/` — Alembic revision 기준으로 컬럼/테이블 정의 확인 +- External: 없음 + +## Design Gate + +- Design Required Before Implementation: no +- Reason: 신규 코드/인터페이스/DB 스키마/런타임 흐름 변경 없음. 구현 완료된 REQ-004~010 결과를 기존 3개 문서에 반영하는 doc-only 작업. 구조적 결정 사항 없음. + +## Milestones + +1. docs/interface-contract.md 갱신 완료 (v1 API 엔드포인트 4개 + 인증 방식) +2. docs/data-model.md 갱신 완료 (테이블 2개 + 컬럼 2개) +3. docs/runtime-flows.md 갱신 완료 (v1 API 처리 흐름 + runtime_id 발급 흐름) + +## Risks + +- `docs/runtime-flows.md`에 BAT-007에서 추가된 섹션과 중복 또는 충돌이 생길 수 있음 — 1단계 현황 파악에서 확인 후 통합 방식 결정 +- 실제 구현 코드와 문서 내용 불일치 시 코드 기준으로 작성 diff --git a/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/verification.md b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/verification.md new file mode 100644 index 0000000..ead5d06 --- /dev/null +++ b/docs/batches/bat-008_20260511_trex-basic-baseline-docs-update/verification.md @@ -0,0 +1,59 @@ +# BAT-008 Verification + +- Status: passed +- Batch: BAT-008 +- Profile: batch-lite +- Architecture Impact: none (confirmed — doc-only, no code/schema/runtime changes) +- Design Gate: no (planning.md confirmed — no structural decisions required) + +## Acceptance Mapping + +### REQ-011 AC-1: docs/interface-contract.md에 4개 엔드포인트 명세 포함 + +| Criterion | Evidence | Result | +|-----------|----------|--------| +| GET /health 응답 스키마 포함 | `docs/interface-contract.md` Interface: Primary HTTP API — Outputs 섹션에 `{status, service, version, checked_at}` 명시 (HealthResponse from app/api/health.py 기준) | PASS | +| GET /v1/assets 요청/응답 명세 포함 | `docs/interface-contract.md` Endpoint: GET /v1/assets — query param, AssetsResponse/AssetItem 스키마, HTTP 200/401/403 명시 | PASS | +| GET /v1/user_info 요청/응답 명세 포함 | `docs/interface-contract.md` Endpoint: GET /v1/user_info — UserInfoResponse/BrokerItem 스키마, HTTP 200/401/403/404 명시 | PASS | +| POST /v1/assets/{asset_id}/ledger/trades 요청/응답 명세 포함 | `docs/interface-contract.md` Endpoint: POST /v1/assets/{asset_id}/ledger/trades — LedgerTradeRequest/LedgerTradeResponse 스키마, HTTP 201/200/409/404/401/403 명시 | PASS | +| service-token bearer 인증 방식 포함 | `docs/interface-contract.md` Authentication 섹션 — DB-issued service token 조건, trade-write 권한 명시 | PASS | + +**REQ-011 AC-1: PASS** + +### REQ-011 AC-2: docs/data-model.md에 신규 테이블/컬럼 문서화 + +| Criterion | Evidence | Result | +|-----------|----------|--------| +| broker_credentials 테이블 문서화 | `docs/data-model.md` Entity: BrokerCredential — (user_id, broker) PK, account_ref, encrypted_payload, credential_version, last_rotated_at; migration 20260502_0014 기준 | PASS | +| ledger_trades 테이블 문서화 | `docs/data-model.md` Entity: LedgerTrade — ledger_entry_id (BigInt Identity), asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at; uq_ledger_trades_idempotency unique constraint 명시 | PASS | +| portfolios.market 컬럼 문서화 | `docs/data-model.md` Entity: Portfolio Key Fields — `market :: text\|null` 추가 | PASS | +| auth_tokens.runtime_id 컬럼 문서화 | `docs/data-model.md` Entity: AuthToken — `runtime_id :: text\|null` 및 idx_auth_tokens_runtime_id_unique 명시 | PASS | + +**REQ-011 AC-2: PASS** + +### REQ-011 AC-3: docs/runtime-flows.md에 v1 API 처리 흐름과 runtime_id 발급 흐름 포함 + +| Criterion | Evidence | Result | +|-----------|----------|--------| +| v1 API 처리 흐름 포함 | `docs/runtime-flows.md` — Flow: v1 API — Service-Token Authentication, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades (bat-001에서 추가, 현행 코드와 일치) | PASS | +| runtime_id 발급 흐름 포함 | `docs/runtime-flows.md` — Flow: v1 API — POST /auth/service-tokens: "Calls AuthService.issue_service_token() which creates a new DB token with runtime_id" (bat-001에서 추가) | PASS | + +**REQ-011 AC-3: PASS** + +## Architecture Impact Confirmation + +- batch-lite, Design Gate = no +- 코드/DB 스키마/런타임 흐름 변경 없음 +- architecture impact: none ✓ + +## Blocking Issues + +- 없음 + +## Ready for Release + +- true + +## Notes + +- docs/runtime-flows.md의 v1 API flows는 bat-001 (BAT-001 scope) 에서 이미 작성됨. REQ-011이 명시한 "v1 API 처리 흐름과 runtime_id 발급 흐름" 조건은 bat-001 결과로 충족되어 있었으며, bat-008은 이를 공식 확인하고 Source Discovery를 갱신함. diff --git a/docs/batches/index.md b/docs/batches/index.md index 619bee7..3393bb3 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -12,4 +12,7 @@ | BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) | | BAT-003 | batch-lite | release-candidate | REQ-013 | dcy-003 | [bat-003_20260502_portfolio-list-user-required](./bat-003_20260502_portfolio-list-user-required/) | | BAT-004 | batch-lite | release-candidate | REQ-014 | minor-change | [bat-004_20260502_portfolio-create-user-required](./bat-004_20260502_portfolio-create-user-required/) | -| BAT-005 | batch-lite | release-candidate | REQ-015 | minor-change | [bat-005_20260503_broker-credential-cli](./bat-005_20260503_broker-credential-cli/) | \ No newline at end of file +| BAT-005 | batch-lite | release-candidate | REQ-015 | minor-change | [bat-005_20260503_broker-credential-cli](./bat-005_20260503_broker-credential-cli/) | +| BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018, REQ-019 | dcy-004 | [bat-006_20260511_portfolio-uuid-migration](./bat-006_20260511_portfolio-uuid-migration/) | +| BAT-007 | batch-lite | release-candidate | REQ-001, REQ-002, REQ-003 | dcy-001 | [bat-007_20260511_local-db-validation-and-operations-runbook](./bat-007_20260511_local-db-validation-and-operations-runbook/) | +| BAT-008 | batch-lite | release-candidate | REQ-011 | dcy-002 | [bat-008_20260511_trex-basic-baseline-docs-update](./bat-008_20260511_trex-basic-baseline-docs-update/) | \ No newline at end of file diff --git a/docs/data-model.md b/docs/data-model.md index e9138a9..f2bca1e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-04-30 22:13 -- Source Discovery / Batch: bootstrap-baseline +- Last Updated (KST): 2026-05-11 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -29,6 +29,9 @@ - Portfolio :: owned investment portfolio with balances, strategy references, tags, comments, and ledger history - User :: authenticated principal that owns portfolios and receives client or service tokens +- AuthToken :: issued client or service token with permission and optional runtime_id +- BrokerCredential :: encrypted broker API credentials per user-broker pair +- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id) ## Entity: `Portfolio` @@ -38,22 +41,29 @@ ### Key Fields -- portfolio_id :: text :: stable portfolio primary key +- portfolio_id :: uuid :: server-generated UUID primary key; assigned at creation and never changed; BAT-006 migrated all pre-existing TEXT portfolio_id values to UUIDs +- name :: text :: human-readable portfolio name; unique per owner_user_id; validated against [a-zA-Z0-9._:-]{1,100} - symbol :: text :: traded asset symbol for the portfolio +- market :: text|null :: market identifier for the asset (e.g., exchange code); added by migration 20260502_0014 - owner_user_id :: text|null :: owning user reference for attribution and authorization - active_strategy_id :: text|null :: currently selected strategy-set reference - cash_balance :: double precision :: current cash snapshot after the latest ledger event - asset_quantity :: double precision :: current holdings snapshot after the latest ledger event +### Uniqueness Constraints + +- uq_portfolios_owner_name :: UNIQUE (owner_user_id, name) :: enforces per-user portfolio name uniqueness at the DB level + ### Relationships - User :: owner_user_id links a portfolio to its owning user when ownership is assigned - StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions -- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id +- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id (UUID) ### State Rules -- Portfolio names are required and are backfilled from symbol when older rows are migrated. +- Portfolio names are required, must be unique per owner_user_id, and are validated at the schema layer before persistence. +- portfolio_id is server-generated at creation; clients must never supply or assume a specific portfolio_id value. - Balance-changing commands must update snapshot fields and append a ledger row together. - Tags are unique per portfolio and comments are retrieved in created_at descending order. @@ -84,11 +94,95 @@ - Bootstrap admin creation only runs when the users table is empty and DB mode is enabled. - Permission changes affect both API guards and CLI mutation capabilities. +## Entity: `AuthToken` + +- Purpose: represent an issued client or service token linked to a user with permission and optional runtime identifier +- Source of Truth: auth_tokens table +- Lifecycle: issued -> optionally rotated -> revoked or expired + +### Key Fields + +- id :: text :: primary key; raw token value (stored hashed in practice) +- user_id :: text :: owning user reference +- token_type :: text :: "client" or "service" +- token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens +- permission :: text :: read, trade-write, or admin +- service_name :: text|null :: logical name for service tokens (unique within token_type="service") +- runtime_id :: text|null :: opaque runtime identifier assigned at service-token issuance; unique (partial index on non-NULL values); added by migration 20260502_0014 +- expires_at :: timestamptz|null :: token expiry; null for non-expiring tokens +- created_at :: timestamptz :: issuance timestamp + +### Uniqueness Constraints + +- idx_auth_tokens_service_name_unique :: UNIQUE (service_name) WHERE token_type='service' +- idx_auth_tokens_runtime_id_unique :: UNIQUE (runtime_id) WHERE runtime_id IS NOT NULL + +### State Rules + +- Service tokens with runtime_id are the only tokens accepted by v1 API endpoints; GET /v1/user_info returns HTTP 404 if runtime_id is None. +- Token permission gates both HTTP API guards and CLI mutation capabilities. + +## Entity: `BrokerCredential` + +- Purpose: store Fernet-encrypted broker API credentials per user-broker pair for the trex-basic integration +- Source of Truth: broker_credentials table (added by migration 20260502_0014) +- Lifecycle: upserted via CLI credential command -> optionally rotated + +### Key Fields + +- user_id :: text :: owning user reference (part of composite PK) +- broker :: text :: broker identifier, e.g., "KIS" (part of composite PK) +- account_ref :: text :: broker account reference or alias +- encrypted_payload :: text :: Fernet-encrypted JSON ciphertext; plaintext is `{"app_key": ..., "app_secret": ...}`; decrypted at query time using CREDENTIAL_ENCRYPTION_KEY (REQ-010) +- credential_version :: integer :: monotonically increasing rotation counter; server_default=1 +- last_rotated_at :: timestamptz :: last credential upsert or rotation timestamp; server_default=NOW() + +### Uniqueness Constraints + +- PRIMARY KEY (user_id, broker) :: one credential entry per user per broker + +### State Rules + +- encrypted_payload must never contain plaintext credentials; Fernet encryption is mandatory before persistence. +- Decryption failures (wrong key, corrupted payload) log a warning and return `credential_payload: null` in GET /v1/user_info. + +## Entity: `LedgerTrade` + +- Purpose: record idempotent trade execution entries submitted by trex-basic via POST /v1/assets/{asset_id}/ledger/trades +- Source of Truth: ledger_trades table (added by migration 20260502_0014) +- Lifecycle: inserted once per unique (asset_id, order_id, execution_id) tuple; never updated or deleted + +### Key Fields + +- ledger_entry_id :: bigint (Identity) :: auto-generated sequential primary key +- asset_id :: text :: portfolio_id (UUID-format text after BAT-006) referencing the trading portfolio +- order_id :: text :: broker-assigned order identifier +- execution_id :: text :: broker-assigned execution/fill identifier +- side :: text :: "buy" or "sell" +- quantity :: numeric(20,8) :: executed quantity +- price :: numeric(20,8) :: execution price +- fee_amount :: numeric(20,8) :: brokerage fee; server_default=0 +- tax_amount :: numeric(20,8) :: transaction tax; server_default=0 +- executed_at :: timestamptz :: broker-reported execution timestamp +- recorded_at :: timestamptz :: insertion timestamp; server_default=NOW() + +### Uniqueness Constraints + +- uq_ledger_trades_idempotency :: UNIQUE (asset_id, order_id, execution_id) :: idempotency guarantee; duplicate inserts are detected and return HTTP 200 + status="duplicate" +- idx_ledger_trades_asset_executed :: INDEX (asset_id, executed_at) :: time-range query optimization + +### State Rules + +- Rows are immutable after insertion; no UPDATE path exists. +- Payload conflict detection: if unique key matches but payload differs → HTTP 409 Conflict. +- asset_id is TEXT (not a FK) but must correspond to a valid portfolios.portfolio_id before insertion. + ## Shared Consistency Rules - auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run. - strategy_sets.name plus config, strategy_sets.nick, and service token names have uniqueness constraints. - Portfolio snapshot fields and portfolio_ledger history must stay synchronized across mutations. +- (owner_user_id, name) uniqueness is enforced at both the DB constraint level and the application layer; violations surface as a 409 Conflict or ValueError with a Korean message. ## Persistence / Integration Notes @@ -110,4 +204,6 @@ ## Change Log +- 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary. +- 2026-05-11 KST: bat-006 — portfolios.portfolio_id changed to server-generated UUID PK; name column added with UNIQUE (owner_user_id, name) constraint; uq_portfolios_owner_name constraint documented; state rules updated. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. \ No newline at end of file diff --git a/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md index 9b53f69..8fc292c 100644 --- a/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md +++ b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md @@ -141,6 +141,7 @@ - A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항) - A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항) - A-3: `GET /v1/assets`의 `asset_id`는 기존 `portfolio_id`를 그대로 사용한다. 별도 asset 식별자 체계를 만들지 않는다. + - **[철회 — 2026-05-11 REQ-018]**: REQ-016 UUID 마이그레이션 완료 이후 `portfolio_id`는 server-generated UUID로 변경된다. 따라서 `asset_id == portfolio_id (text)` 전제는 `asset_id == portfolio_id (UUID)`로 대체되었다. 가정 A-3의 "별도 asset 식별자 체계를 만들지 않는다"는 원칙은 유지되지만, 실제 값 형식이 UUID로 바뀐다는 점에서 원문 가정의 텍스트 동일성 가정은 철회한다. - A-4: `trading_enabled`는 현재 단계에서 `users.is_active`와 연동하는 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope다. - A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다. diff --git a/docs/discovery/dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md b/docs/discovery/dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md index 87bf880..eba545f 100644 --- a/docs/discovery/dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md +++ b/docs/discovery/dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md @@ -1,14 +1,18 @@ # 0. 문서 상태 이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. -- 상태: draft +- 상태: confirmed - 문서 ID: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution - 이슈명: portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 - 작성 시각(KST): 2026-05-11 00:00 -- 마지막 갱신 시각(KST): 2026-05-11 00:00 +- 마지막 갱신 시각(KST): 2026-05-11 (draft-req 완료 — REQ-016~019 생성) - 대체됨: 없음 - 후속 Discovery 참조: 없음 -- 생성된 REQ 참조: 없음 +- 생성된 REQ 참조: + - [REQ-016](../srs/Migration/req-016_portfolios-uuid-schema-migration.md) — portfolios 테이블 UUID 전환 및 데이터 마이그레이션 (FR-1, FR-5, NFR-1) + - [REQ-017](../srs/Interface/req-017_portfolio-cli-name-user-identification.md) — CLI portfolio 명령 name+user 조합 식별 방식 전환 (FR-2, FR-3, FR-3a, NFR-2) + - [REQ-018](../srs/Interface/req-018_portfolio-http-api-uuid-path-param.md) — HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 (FR-4) + - [REQ-019](../srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md) — portfolio UUID 전환 관련 baseline 문서 갱신 (FR-6, NFR-3) # 1. 계획 상태 요약 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. @@ -50,7 +54,7 @@ - 제약사항 - 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수). - dcy-002 A-3("asset_id는 portfolio_id를 그대로 사용")은 이번 Discovery에서 철회된다. trex-basic 팀에게 breaking change를 사전 통보해야 한다. - - `name`은 owner 내에서 유일하되, 허용 문자 범위(공백, 특수문자 등)는 REQ 단계에서 결정한다. + - `name`은 owner 내에서 유일하며, 허용 문자는 `[a-zA-Z0-9._:-]`, 공백 불허, 1~100자, 대소문자 구분으로 확정됐다 (DATA_NAME_ALLOWED_CHARS Answered 2026-05-11). ## 주요 사용자 시나리오 사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. @@ -96,6 +100,7 @@ - FR-1: `portfolios.portfolio_id`를 서버 생성 UUID(TEXT)로 교체한다. 기존 사용자 지정 portfolio_id 문자열은 `portfolios.name` TEXT 컬럼으로 이전한다. `(owner_user_id, name)` 복합 UNIQUE 제약을 DB 레벨에서 보장한다. - FR-2: `portfolio create` CLI 명령은 `name` Argument와 `--user` 옵션을 필수로 받고, `portfolio_id`는 서버가 UUID로 발급한다. 사용자는 UUID를 직접 지정하거나 조회할 수 없다. - FR-3: CLI 뮤테이션 명령(`status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update`)은 `` Argument와 `--user ` 옵션으로 portfolio를 식별한다. 내부에서 `(owner_user_id, name)` → `portfolio_id(UUID)`를 조회한 뒤 처리한다. `name` + `--user` 조합으로 portfolio를 찾지 못하면 명확한 오류를 반환한다. +- FR-3a: `name`은 생성 후 변경 가능하다. 단, 변경 시 동일 `owner_user_id` 내에 같은 `name`이 이미 존재하면 DB의 `(owner_user_id, name)` UNIQUE 제약으로 변경이 거부되고, 서비스 레이어는 사용자에게 명확한 오류 메시지(`이미 사용 중인 이름입니다: {name}`)를 반환한다. - FR-4: HTTP API의 portfolio 관련 path parameter를 UUID 기반으로 전환한다. `GET /v1/assets` 응답의 `asset_id` 필드는 UUID를 반환한다. dcy-002 A-3을 이번 Discovery에서 철회한다. - FR-5: 기존 데이터 마이그레이션 — 현재 `portfolio_id` 문자열 값을 `name` 컬럼으로 복사하고, 각 레코드에 새 UUID를 `portfolio_id`로 할당한다. `portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades` 등 FK 참조 테이블의 portfolio_id도 새 UUID로 함께 갱신한다. - FR-6: `docs/project-structure.md`와 `docs/runtime-flows.md`를 이번 변경 내용에 맞게 갱신한다(repositories/services 인터페이스 변경, CLI 흐름, v1 API 흐름 변경 반영). @@ -105,7 +110,7 @@ - NFR-1: 데이터 마이그레이션은 단일 Alembic revision으로 수행하며, IF NOT EXISTS 패턴을 준수해 재실행 시 오류가 없어야 한다(REQ-002 준수). FK 참조 테이블과의 갱신은 트랜잭션 내에서 atomic하게 처리한다. - NFR-2: CLI `(owner_user_id, name)` → UUID 조회는 단일 DB 쿼리로 처리하며, 조회 실패 시 명확한 오류 메시지(`포트폴리오를 찾을 수 없습니다: {name} (user: {username})`)를 출력해야 한다. -- NFR-3: trex-basic 계약 변경(asset_id UUID 전환)은 배포 전에 trex-basic 팀에게 통보하고, 변경 일정을 docs/interface-contract.md에 기록해야 한다. +- NFR-3: trex-basic 계약 변경(asset_id UUID 전환)은 portfolio-hub와 trex-basic을 동시에 배포하며 처리한다. 변경 일정 및 동시 배포 계획을 docs/interface-contract.md에 기록해야 한다. ## 범위 경계 이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. @@ -122,8 +127,8 @@ - Out of Scope - trex-basic 측 코드 변경 — portfolio-hub API 변경만 담당 - `name` 변경 이력 감사 로그 — 향후 반복으로 Deferred - - `name` 허용 문자 유효성 검사 정책 — REQ 단계에서 결정 - - HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 — {{OUT_OF_SCOPE_API_NAME_LOOKUP}} 결정 필요 + - `name` 허용 문자 유효성 검사 정책 — 확정됨. `[a-zA-Z0-9._:-]` 허용, 공백 불허, 1~100자, 대소문자 구분 (2026-05-11) + - HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가 — 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회 (OQ-3 Answered 2026-05-11) # 6. 리스크/가정 목록 불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. @@ -132,9 +137,9 @@ - R-1 (High): 기존 FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)의 portfolio_id를 새 UUID로 일괄 갱신하는 마이그레이션 중 오류 발생 시 데이터 불일치가 생길 수 있다. - 완화 방안: 마이그레이션 전 DB 전체 백업 수행. 마이그레이션 스크립트를 단일 트랜잭션으로 작성하고, 검증 쿼리(FK 고아 레코드 0건 확인)를 마이그레이션 스크립트 마지막에 실행한다. - R-2 (High): `GET /v1/assets` `asset_id`가 UUID로 바뀌면 trex-basic 데몬의 `POST /v1/assets/{asset_id}/ledger/trades` 경로가 즉시 동작하지 않는다. 배포 타이밍 불일치 시 운영 장애가 발생한다. - - 완화 방안: trex-basic 팀과 변경 일정 사전 조율. portfolio-hub 배포 전 trex-basic 코드 업데이트가 선행되거나 동시 배포한다. 임시 호환성 레이어(기존 name으로 fallback 조회) 필요 여부를 REQ 단계에서 결정한다(OQ-1). -- R-3 (Medium): `name`을 mutable로 허용하면 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨진다. - - 완화 방안: name을 immutable(생성 후 변경 불가)로 정책을 확정하거나, 변경 시 경고와 함께 명시적 확인 절차를 요구한다. 결정은 OQ-2에서 처리한다. + - 완화 방안: trex-basic과 portfolio-hub를 동시에 배포한다(OQ-1 Answered). 임시 호환 레이어 불필요. 두 저장소의 배포 파이프라인에서 릴리즈 타이밍을 조율한다. +- R-3 (Medium): `name`이 mutable이므로 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨질 수 있다. + - 완화 방안: name 변경 CLI 명령에 명시적 확인 절차(--force 또는 confirm 프롬프트)를 요구한다. DB의 `(owner_user_id, name)` UNIQUE 제약이 같은 사용자 내 중복 name 변경을 자동으로 차단한다(OQ-2 Answered). ## 가정 아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. @@ -142,23 +147,23 @@ - A-1: `portfolio_id`(UUID)는 서버에서 `uuid.uuid4()`로 생성하며, 사용자가 직접 지정하거나 예측할 수 없다. - A-2: `name`은 `(owner_user_id, name)` 복합 UNIQUE 제약을 만족하면 되며, 전역 유일성은 요구하지 않는다. - A-3: 기존 `portfolio_id` 문자열(`005930:tv` 형식)은 데이터 마이그레이션 시 `name` 컬럼 값으로 그대로 이전한다. 변환 없이 복사한다. -- A-4: HTTP API 경로에서 `(username, name)` 조합 조회 엔드포인트 추가는 이번 반복 Out of Scope로 잠정 처리하고, CLI 경로만 먼저 전환한다. +- A-4: HTTP API 경로에서 `(username, name)` 조합 조회 엔드포인트는 추가하지 않는다. HTTP API는 `portfolio_id`(UUID)만으로 portfolio를 식별한다. CLI만 `(owner_user_id, name)` → UUID 조회 경로를 사용한다(OQ-3 Answered 2026-05-11). ## 오픈 질문 답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. - OQ-1: trex-basic 계약 호환성 전략 — `GET /v1/assets` asset_id가 UUID로 바뀔 때 trex-basic 측의 대응 준비 일정이 확정되기 전에 portfolio-hub를 먼저 배포할 수 있는지, 아니면 임시 호환 레이어(name으로 fallback 조회)가 필요한지. - - 상태: Open - - 처리 방안: trex-basic 팀과 협의 후 결정. 배포 순서 또는 호환 레이어 필요 여부를 REQ 단계 진입 전에 확정한다. - - 종료 조건: trex-basic 팀 확인 완료 또는 임시 호환 레이어 구현 방안 확정 시 Answered 처리. + - 상태: Answered + - 처리 방안: trex-basic과 portfolio-hub를 동시에 변경·배포한다. 임시 호환 레이어 불필요. + - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리. - OQ-2: `name` mutable 정책 — 생성 이후 name 변경을 허용할지 여부. 허용할 경우 기존 CLI 스크립트 참조 안정성 확보 방안. - - 상태: Open - - 처리 방안: 사용자(Justin) 결정. 단순성을 위해 immutable을 기본 제안으로 두고 확인한다. - - 종료 조건: 사용자 확인 후 Answered 처리. 결과를 NFR 또는 FR에 반영. + - 상태: Answered + - 처리 방안: name 변경 허용. 단, 동일 사용자 내 같은 name이 존재하면 DB의 `(owner_user_id, name)` UNIQUE 제약으로 변경 거부. FR-3a에 반영 완료. + - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리. - OQ-3: HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 여부 — 외부 API 소비자가 UUID를 직접 관리해야 하는 부담을 줄이기 위해 `/portfolios?user=alice&name=tv` 형태의 조회 경로가 필요한지. - - 상태: Open - - 처리 방안: 사용자(Justin) 결정. 현재 trex-basic은 GET /v1/assets로 UUID를 조회하므로 별도 엔드포인트 불필요할 수 있다. - - 종료 조건: 사용자 확인 후 Answered 또는 Deferred 처리. + - 상태: Answered + - 처리 방안: 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회. A-4, Out of Scope 반영 완료. + - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리. # 7. 초기 성공 기준 이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. @@ -179,15 +184,15 @@ # 8. REQ로 넘기기 전 확인 체크 REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. -- [ ] 범위(In Scope/Out of Scope) 확정 +- [x] 범위(In Scope/Out of Scope) 확정 - 기준: OQ-3(HTTP API name 조회 엔드포인트) 결정 완료, Out of Scope 경계 승인 -- [ ] 성공 지표의 측정 방식/데이터 출처 확정 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 - 기준: S-1~S-4 측정 방식이 pytest 또는 코드 리뷰로 추적 가능한 형태로 기술됨 -- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 - 기준: R-1(마이그레이션 데이터 무결성), R-2(trex-basic 계약 파괴) 완화 방안 확정 및 담당자 지정 -- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 -- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - 기준: Justin 또는 지정된 승인자가 confirm-discovery skill로 Freeze 확정 # 9. 파일 처리 결과 @@ -207,15 +212,15 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. ## DECIDE -- {{DECIDE_NAME_MUTABILITY}}: `name` 컬럼의 변경 허용 여부 — immutable(생성 후 변경 불가) 또는 mutable(변경 허용, 단 확인 절차 필요) 중 선택 (OQ-2) -- {{DECIDE_TREX_COMPAT_STRATEGY}}: trex-basic 계약 호환성 전략 — portfolio-hub 단독 배포 후 trex-basic 후속 배포 허용 여부, 또는 임시 호환 레이어(name fallback 조회) 구현 필요 여부 (OQ-1) +- ~~{{DECIDE_NAME_MUTABILITY}}~~: **Answered** — name mutable 허용. 동일 사용자 내 중복 name 변경은 DB UNIQUE 제약으로 거부. FR-3a 반영 완료. (OQ-2 2026-05-11) +- ~~{{DECIDE_TREX_COMPAT_STRATEGY}}~~: **Answered** — trex-basic과 portfolio-hub 동시 배포로 확정. 임시 호환 레이어 불필요. (OQ-1 2026-05-11) ## CONFIRM -- {{CONFIRM_API_NAME_LOOKUP_SCOPE}}: HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가가 이번 반복 Out of Scope임을 확인 (OQ-3) -- {{CONFIRM_MIGRATION_STRATEGY}}: 기존 portfolio_id 문자열을 name으로 변환 없이 그대로 복사하는 마이그레이션 전략 확인 (A-3) +- ~~{{CONFIRM_API_NAME_LOOKUP_SCOPE}}~~: **Answered** — `(username, name)` 조합 엔드포인트 추가 안 함. HTTP API는 `portfolio_id`(UUID)만으로 조회. (OQ-3 2026-05-11) +- ~~{{CONFIRM_MIGRATION_STRATEGY}}~~: **Answered** — 기존 `portfolio_id` 문자열을 `name` 컬럼으로 변환 없이 그대로 복사. (A-3 2026-05-11) ## DATA -- {{DATA_NAME_ALLOWED_CHARS}}: `name` 컬럼에 허용할 문자 범위 정의 — 공백 허용 여부, 특수문자 허용 범위, 최대 길이 제한 등 (REQ 단계 결정 필요) +- ~~{{DATA_NAME_ALLOWED_CHARS}}~~: **Answered** — `name` 허용 문자 `[a-zA-Z0-9._:-]`(영숫자 + `.` `_` `:` `-`), 공백 불허, 1~100자, 대소문자 구분 있음. (2026-05-11) # 11. Discovery Freeze REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. @@ -228,8 +233,8 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록 - 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) ## Freeze 플래그 -- Handoff Decision: 보류 -- Handoff Rationale: 초기 Discovery 초안 생성 완료. OQ-1(trex-basic 호환성), OQ-2(name mutable 여부), OQ-3(HTTP API name 조회 엔드포인트) 3개가 Open 상태이며 REQ 초안 작성 전 사용자 확인 필요. -- Ready for REQ Drafting: false -- Confirmed By: TBD -- Confirmed At (KST): TBD +- Handoff Decision: 승인 +- Handoff Rationale: 모든 OQ Answered 완료, 모든 CONFIRM/DATA 항목 확정. confirm-discovery 게이트 통과 — REQ 초안 작성으로 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin +- Confirmed At (KST): 2026-05-11 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 0245c92..9a6b3b9 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -9,4 +9,4 @@ | dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed | | dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed | | dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.md) | confirmed | -| dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution | 20260511 | portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 | [Discovery](./dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md) | draft | \ No newline at end of file +| dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution | 20260511 | portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 | [Discovery](./dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 26b00fe..e6170b6 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-04-30 22:13 -- Source Discovery / Batch: bootstrap-baseline +- Last Updated (KST): 2026-05-11 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008 - Primary Runtime: mixed ## Project Summary @@ -45,7 +45,7 @@ - JSON response models :: responses are shaped by app/schemas/auth.py, app/schemas/user.py, and app/schemas/portfolio.py - HTTP 201, 200, and 204 status codes :: create, read or update, and delete semantics respectively -- GET /health payload :: returns {"status": "ok"} for readiness checks +- GET /health payload :: returns `{"status": "ok", "service": "portfolio-hub", "version": "", "checked_at": ""}` for readiness checks ### Error Contract @@ -72,6 +72,7 @@ - pfhub subcommands :: include user, portfolio, strategy, service-token, health-check, init-db, and migrate-db command families - Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution +- Portfolio identification (bat-006) :: all portfolio mutation commands accept `` (positional) + `--user ` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly ### Outputs @@ -91,6 +92,136 @@ - Write commands require explicit confirmation unless --force is present. - JSON output should remain machine-readable across additive command evolution. +## Interface: `v1 Consumer API (trex-basic)` + +- Type: http-api +- Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002) +- Purpose: provide asset listing, user info, and trade ledger recording for the trex-basic trading daemon +- Stability: internal (versioned under /v1) + +### Authentication + +- All /v1 endpoints require `Authorization: Bearer ` header. +- Token must be a DB-issued service token (`token_source="db"`, `token_type="service"`); env tokens are rejected with HTTP 403. +- POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission. + +### Endpoint: `GET /v1/assets` + +- Auth: service-token bearer (read) +- Query Params: `?status=` (optional, filters by portfolio status) +- Response: HTTP 200 + +``` +{ + "total": , + "assets": [ + { + "asset_id": "", // portfolio.portfolio_id (UUID after BAT-006) + "symbol": "", + "market": "", + "status": "", + "cash": , + "quantity": , + "broker_type": "", + "strategy_id": "", + "strategy_type": "", + "strategy_params": {}, + "updated_at": "", + "name": "", + "price": , + "profit_buffer": , + "tags": []|null, + "owner_user_id": "", + "created_at": "", + "last_trade_at": "" + } + ] +} +``` + +- Error: HTTP 401 if no token; HTTP 403 if not a service token + +### Endpoint: `GET /v1/user_info` + +- Auth: service-token bearer (read); token must have `runtime_id` assigned +- Response: HTTP 200 + +``` +{ + "runtime_id": "", + "trading_enabled": , // mapped from users.is_active + "disabled_reason": "", // "account_inactive" when trading_enabled=false + "brokers": [ + { + "broker": "", + "account_ref": "", + "credential_payload": {}|null, // Fernet-decrypted {app_key, app_secret} + "credential_version": , + "last_rotated_at": "" + } + ], + "user_id": "", + "username": "", + "email": "", + "permission": "" +} +``` + +- Error: HTTP 401 if no token; HTTP 403 if not a service token; HTTP 404 if token has no runtime_id + +### Endpoint: `POST /v1/assets/{asset_id}/ledger/trades` + +- Auth: service-token bearer (trade-write) +- Path Param: `asset_id` — must be a valid UUID portfolio_id +- Request Body: + +``` +{ + "order_id": "", // required, min_length=1 + "execution_id": "", // required, min_length=1 + "side": "buy"|"sell", + "quantity": , // > 0 + "price": , // > 0 + "fee_amount": , // >= 0, default 0 + "tax_amount": , // >= 0, default 0 + "executed_at": "" +} +``` + +- Response HTTP 201 (new record): + +``` +{ + "ledger_entry_id": , + "asset_id": "", + "order_id": "", + "execution_id": "", + "side": "", + "quantity": , + "price": , + "fee_amount": , + "tax_amount": , + "executed_at": "", + "recorded_at": "", + "status": "new" +} +``` + +- Response HTTP 200 (idempotent duplicate with identical payload): same body with `"status": "duplicate"` +- Error: HTTP 401 if no token; HTTP 403 if not service token or insufficient permission; HTTP 404 if asset_id not found; HTTP 409 if duplicate key with different payload + +### Idempotency Contract + +- Deduplication key: `(asset_id, order_id, execution_id)` — enforced by `uq_ledger_trades_idempotency` DB unique constraint +- Identical payload re-send → HTTP 200 + `status="duplicate"`, no new row created +- Same key, different payload → HTTP 409 Conflict + +### Deployment Constraint (bat-006) + +- trex-basic and portfolio-hub must be deployed simultaneously when BAT-006 is released. +- After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format. +- trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed. + ## Shared Constraints - Token permission levels gate both HTTP and CLI mutations consistently. @@ -111,4 +242,6 @@ ## Change Log +- 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract. +- 2026-05-11 KST: bat-006 — Added v1 consumer API interface section (trex-basic); noted asset_id UUID change and simultaneous deployment constraint; updated CLI portfolio inputs to reflect name+user identification. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. \ No newline at end of file diff --git a/docs/project-structure.md b/docs/project-structure.md index 33f6562..5bfc443 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-01 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001 +- Last Updated (KST): 2026-05-11 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007 ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -95,6 +95,7 @@ - Holds framework-light domain models shared by services, routes, and repositories. - app/repositories/ - Provides the repository protocol, repository factory, and concrete Postgres persistence implementation. + - Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands. - app/schemas/ - Defines request and response schemas for HTTP contracts. - `v1.py`: Contract schemas for v1 API (AssetItem, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response). @@ -122,6 +123,30 @@ - docs/ holds SDLC source-of-truth artifacts, while temp/ remains scratch or legacy material outside the active document flow. - .vendor/stage-pilot/ is a vendored upstream toolkit and should not be treated as the active host source of truth. +## Schema Ownership Boundary + +`app/repositories/postgres.py`의 `ensure_schema()`와 `migrations/versions/` Alembic revision 양쪽이 일부 DB 객체를 다룬다. + +### 소유 경계 + +| 소유 경로 | 관리 객체 | 비고 | +|-----------|-----------|------| +| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index) | Alembic revision에 없음 | +| 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 실행 시 충돌 없음 | + +### 허용 실행 순서 + +1. `init-db` 선행 → App 시작 → 정상 운영 (권장) +2. App 시작 (ensure_schema() 선행) → `migrate-db` 후속 실행 (legacy stamp 경로) +3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade + +### 운영 제약 + +- ensure_schema()만 실행한 환경은 v1 API 스키마(`broker_credentials`, `ledger_trades`, `runtime_id`)가 없어 v1 엔드포인트가 동작하지 않는다. `migrate-db`로 보완 필요. +- `alembic_version` 테이블을 수동으로 삭제한 뒤 `migrate-db`를 재실행하면 legacy stamp 오판이 발생할 수 있다. +- legacy DB(bot_principals 등 구 revision 0006~0013 구조)와 신규 revision 간 호환성 검증은 이번 batch 범위 밖이다. + ## Current Gaps / Planned Changes - The non-DB repository path intentionally raises a feature-not-ready error when persistence is disabled. @@ -136,4 +161,6 @@ ## Change Log +- 2026-05-11 KST: bat-007 — Added "Schema Ownership Boundary" section documenting ensure_schema() vs Alembic ownership zones, allowed/forbidden execution sequences, and legacy scope exclusion. +- 2026-05-11 KST: bat-006 — Added get_portfolio_by_name_and_user() to repositories section; noted that portfolio_id is now a server-generated UUID. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. \ No newline at end of file diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 7950cde..7efe8c0 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-01 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001 +- Last Updated (KST): 2026-05-11 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008 - Primary Runtime: mixed ## Project Summary @@ -50,6 +50,17 @@ 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 `` positional argument and `--user ` 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 `` as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response. +4. `update --new-name ` renames the portfolio; `--force` is required to confirm the rename. +5. `comment` and `tag` sub-commands continue to accept `` (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. @@ -71,6 +82,7 @@ 3. Optional `?status=` query param filters portfolios by status field. 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 new field names: `cash`, `quantity`, `broker_type`, `strategy_params`. +6. `asset_id` in each asset item equals `portfolio.portfolio_id` which is a server-generated UUID after BAT-006 migration (dcy-002 A-3 updated: text identity assumption replaced by UUID). ## Flow: `v1 API — GET /v1/user_info` @@ -102,6 +114,50 @@ 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 ` 확인. + - 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 ` 로 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로 보호됨. +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. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행. +4. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). + ## Current Gaps / Planned Changes - End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending. @@ -110,5 +166,8 @@ ## Change Log +- 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. \ No newline at end of file diff --git a/docs/srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md b/docs/srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md new file mode 100644 index 0000000..aaf2c06 --- /dev/null +++ b/docs/srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md @@ -0,0 +1,145 @@ +# req-019: portfolio UUID 전환 관련 baseline 문서 갱신 + +- Status: Implemented +- Type: Documentation +- Priority: Medium +- Owner: repository-maintainers + +## Intent +- REQ-016~018의 구현으로 변경되는 DB 스키마, CLI 흐름, HTTP API 계약을 baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/interface-contract.md`)에 반영해 문서와 구현 간 불일치를 방지한다. +- trex-basic 동시 배포 계획을 `docs/interface-contract.md`에 기록한다. + +## Requirement +- `docs/project-structure.md`를 갱신해 `app/repositories/postgres.py`에 신규 추가된 `get_portfolio_by_name_and_user()` 메서드와 `app/domain/models.py`의 `portfolio_id`(UUID 문자열) 의미 변경을 반영한다. +- `docs/runtime-flows.md`를 갱신해 아래를 반영한다. + - CLI 명령 흐름: `` + `--user ` 조합으로 portfolio를 식별하고 내부에서 UUID를 조회하는 흐름 + - `v1 API — GET /v1/assets` flow: `asset_id` 필드가 UUID를 반환함 +- `docs/data-model.md`를 갱신해 `portfolios` 테이블의 `portfolio_id`(UUID, TEXT, PRIMARY KEY), `name`(TEXT) 신규 컬럼, `(owner_user_id, name)` 복합 UNIQUE 제약을 반영한다. +- `docs/interface-contract.md`를 갱신해 `GET /v1/assets` `asset_id` 필드가 UUID를 반환함을 기록하고, trex-basic과 portfolio-hub의 동시 배포 계획을 명시한다. + +## Acceptance Criteria +1. Given `docs/project-structure.md`를 검토하면, Then `app/repositories/postgres.py` 설명에 `get_portfolio_by_name_and_user()` 메서드가 언급되고, `app/domain/models.py`의 `portfolio_id`가 UUID 기반임이 반영된다. +2. Given `docs/runtime-flows.md`를 검토하면, Then CLI 명령 흐름 설명에 `` + `--user ` 조합 식별 방식이 반영되고, `v1 API — GET /v1/assets` flow에 `asset_id`가 UUID임이 명시된다. +3. Given `docs/data-model.md`를 검토하면, Then `portfolios` 테이블 스키마에 `portfolio_id`(UUID TEXT, PK), `name`(TEXT), `(owner_user_id, name)` UNIQUE 제약이 반영된다. +4. Given `docs/interface-contract.md`를 검토하면, Then `GET /v1/assets` `asset_id` 필드가 UUID를 반환함이 명시되고, trex-basic과 portfolio-hub의 동시 배포 계획이 기록된다. + +## Impacted Area +- Docs: + - `docs/project-structure.md` + - `docs/runtime-flows.md` + - `docs/data-model.md` + - `docs/interface-contract.md` +- Modules: 없음 (문서 전용) +- Tests: 없음 (문서 전용) + +## Notes +- 이 REQ는 REQ-016, REQ-017, REQ-018 구현 완료 후 또는 동일 batch에서 함께 처리하는 것이 적절하다. +- trex-basic 측 코드 변경은 이번 범위 밖이며, 동시 배포 계획을 문서화하는 것만 이 REQ 범위에 포함된다. +- NFR-1(단일 revision, IF NOT EXISTS, atomic)은 REQ-016에 흡수. NFR-2(단일 DB 쿼리, 오류 메시지)는 REQ-017에 흡수. NFR-3(동시 배포 계획 기록)은 이 REQ에서 처리. +- Source Discovery: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution (FR-6, NFR-3) + +## Change Log + +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- 최초 생성 — dcy-004 draft-req에 의해 생성 + +#### Intent +- dcy-004 Discovery의 FR-6(docs/project-structure.md, docs/runtime-flows.md 갱신), NFR-3(동시 배포 계획 docs/interface-contract.md 기록) 반영. docs/data-model.md, docs/interface-contract.md 갱신 포함. + +#### Acceptance Criteria Delta +- Added: + - 1~4 (최초 생성) + +#### Impacted Area Delta +- Docs: + - docs/project-structure.md + - docs/runtime-flows.md + - docs/data-model.md + - docs/interface-contract.md + +#### Delivery Trace Delta +- Batches: none (batch 미결정) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req → suggest-batch-reqs → draft-batch + +#### Validation Plan +- 각 baseline 문서에 변경 내용이 포함됐는지 코드 리뷰로 확인 + +### CHG-20260511-02 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Approved 전환 — confirm-req 게이트 통과 + +#### Intent +- Delivery 입력으로 사용할 수 있도록 REQ-019를 승인 상태로 승격한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - none +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - none +- Modules: + - none +- Tests: + - none + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs로 batch grouping 검토 + +#### Validation Plan +- confirm-req 게이트 재평가 결과: 필수 메타데이터, 구체적 Acceptance Criteria, Impacted Area, Change Log, blocker 없음 확인 + +### CHG-20260511-03 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-006 verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-006 verification.md의 REQ-019 AC-1~4 모두 baseline 문서 변경 내용 직접 확인으로 evidence 충족. 문서 전용 REQ로 테스트 불필요. Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-006 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-006) + +#### Validation Plan +- BAT-006 verification.md AC-1~4: project-structure.md / runtime-flows.md / data-model.md / interface-contract.md 각 문서 변경 내용 manual 확인 완료 diff --git a/docs/srs/Interface/req-004_health-response-schema-extension.md b/docs/srs/Interface/req-004_health-response-schema-extension.md index 062be2a..d6ed779 100644 --- a/docs/srs/Interface/req-004_health-response-schema-extension.md +++ b/docs/srs/Interface/req-004_health-response-schema-extension.md @@ -1,6 +1,6 @@ # req-004: GET /health 응답 스키마 확장 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: repository-maintainers @@ -32,6 +32,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC2(degraded/failed 경로)는 REQ Notes에서 이번 반복 OOS로 명시 — 릴리스 블로커 아님. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001 기반 release 편성 검토) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md index de9da3e..b58a656 100644 --- a/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md +++ b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md @@ -1,6 +1,6 @@ # req-005: service-token 발급 시 runtime_id 동시 생성 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: repository-maintainers @@ -34,6 +34,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC5 DB 미확인은 REQ-009와 동일 제약으로 non-blocker 처리. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-006_v1-assets-endpoint.md b/docs/srs/Interface/req-006_v1-assets-endpoint.md index f1ff302..7e633c7 100644 --- a/docs/srs/Interface/req-006_v1-assets-endpoint.md +++ b/docs/srs/Interface/req-006_v1-assets-endpoint.md @@ -1,6 +1,6 @@ # req-006: GET /v1/assets 엔드포인트 구현 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: repository-maintainers @@ -39,6 +39,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03, CHG-20260503-01 strategy_type 재검증 포함) AC-1~9 모두 PASS. Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~9 evidence 연결 확인 완료 (strategy_type 필드 재검증 포함) + ### CHG-20260503-01 - Date: 2026-05-03 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-007_v1-user-info-endpoint.md b/docs/srs/Interface/req-007_v1-user-info-endpoint.md index 4023aaa..6e0a1f4 100644 --- a/docs/srs/Interface/req-007_v1-user-info-endpoint.md +++ b/docs/srs/Interface/req-007_v1-user-info-endpoint.md @@ -1,6 +1,6 @@ # req-007: GET /v1/user_info 엔드포인트 구현 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: repository-maintainers @@ -38,6 +38,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~7 모두 PASS. Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~7 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md index 5ec4b85..9194e88 100644 --- a/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md +++ b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md @@ -1,6 +1,6 @@ # req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: repository-maintainers @@ -36,6 +36,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/Interface/req-017_portfolio-cli-name-user-identification.md b/docs/srs/Interface/req-017_portfolio-cli-name-user-identification.md new file mode 100644 index 0000000..30fb8e0 --- /dev/null +++ b/docs/srs/Interface/req-017_portfolio-cli-name-user-identification.md @@ -0,0 +1,151 @@ +# req-017: CLI portfolio 명령 — name+user 조합 식별 방식 전환 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- 현재 `portfolio_id` Argument로만 portfolio를 식별하는 CLI 뮤테이션 명령을 `` + `--user ` 조합으로 전환해 소유권 검증을 명령 실행 경로에 포함한다. +- CLI 내부에서 `(owner_user_id, name)` → UUID 단일 쿼리 조회 후 처리하고, 조회 실패 시 명확한 오류 메시지를 출력한다. + +## Requirement +- `portfolio create` 명령은 `name` Argument와 `--user ` 옵션을 필수로 받는다. `portfolio_id`는 서버가 UUID로 발급하며 사용자가 직접 지정하거나 조회할 수 없다. +- `status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update` 명령은 `` Argument와 `--user ` 옵션으로 portfolio를 식별한다. 내부에서 `(owner_user_id, name)` → `portfolio_id(UUID)`를 단일 DB 쿼리로 조회한 뒤 처리한다. +- `` + `--user` 조합으로 portfolio를 찾지 못하면 오류 메시지 `포트폴리오를 찾을 수 없습니다: {name} (user: {username})`를 출력하고 명령을 중단한다. +- `name`은 `update` 명령으로 변경 가능하다. 변경 시 동일 `owner_user_id` 내에 같은 `name`이 이미 존재하면 오류 메시지 `이미 사용 중인 이름입니다: {name}`를 반환한다. +- `name` 변경 명령에는 명시적 확인 절차(`--force` 또는 confirm 프롬프트)를 요구한다. +- `name` 허용 문자는 `[a-zA-Z0-9._:-]`이며, 공백 불허, 1~100자, 대소문자 구분이 있다. 이 범위를 벗어나면 유효성 검사 오류를 반환한다. + +## Acceptance Criteria +1. Given `pfhub portfolio create --user ...`을 실행하면, When 서버가 UUID를 발급하면, Then CLI가 portfolio_id를 직접 지정하거나 조회하지 않고 UUID가 DB에 저장된다. +2. Given `pfhub portfolio buy --user alice ...`를 실행하면, When `(alice, )` 조합으로 DB를 단일 쿼리로 조회해 UUID를 찾으면, Then 해당 portfolio에 buy 작업이 수행된다. +3. Given `--user bob`으로 alice 소유의 portfolio ``을 조작하면, When `(bob, )` 조합으로 조회해 레코드가 없으면, Then `포트폴리오를 찾을 수 없습니다: {name} (user: bob)` 오류가 출력되고 명령이 중단된다. +4. Given alice와 bob이 동일 name `tv`로 각각 portfolio를 생성하면, Then 두 레코드가 독립적으로 저장되고 각자 자신의 `tv` portfolio를 조작할 수 있다. +5. Given alice의 portfolio name을 `tv2`로 변경할 때 alice에게 이미 `tv2` 이름의 portfolio가 존재하면, When `update` 명령이 DB의 `(owner_user_id, name)` UNIQUE 제약 위반을 감지하면, Then `이미 사용 중인 이름입니다: tv2` 오류를 출력한다. +6. Given `--force` 없이 `update` 명령으로 name을 변경하려 하면, Then `--force`가 없으면 변경이 중단되거나 confirm 프롬프트가 표시된다. +7. Given `name` 허용 문자 범위(`[a-zA-Z0-9._:-]`) 밖의 문자가 포함된 이름을 `create` 시 입력하면, Then 유효성 검사 오류가 반환된다. +8. Given `name` 길이가 0이거나 100자를 초과하면, Then 유효성 검사 오류가 반환된다. + +## Impacted Area +- Docs: `docs/runtime-flows.md` (CLI 명령 흐름 변경 반영 — REQ-019에서 처리) +- Modules: + - `app/cli/commands/portfolio.py`: `create`, `status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update` 명령의 Argument/Option 전환 + - `app/domain/services.py`: `(user_id, name)` → UUID 해석 로직 추가 + - `app/repositories/postgres.py`: `get_portfolio_by_name_and_user()` 단일 쿼리 구현 (REQ-016과 공유) +- Tests: + - `tests/test_cli.py`: 소유권 검증 케이스, 오류 메시지 검증, name 유효성 검사, name 중복 변경 거부 테스트 + +## Notes +- `(owner_user_id, name)` → UUID 조회는 REQ-016에서 추가하는 `get_portfolio_by_name_and_user()` 메서드를 사용한다. +- `name` 대소문자 구분 있음 — `TV`와 `tv`는 다른 이름으로 처리. +- Source Discovery: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution (FR-2, FR-3, FR-3a, NFR-2) + +## Change Log + +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- 최초 생성 — dcy-004 draft-req에 의해 생성 + +#### Intent +- dcy-004 Discovery의 FR-2(portfolio create 명령 name+user 전환), FR-3(CLI 뮤테이션 명령 식별 방식 전환), FR-3a(name mutable + 중복 변경 거부), NFR-2(단일 DB 쿼리, 오류 메시지) 반영 + +#### Acceptance Criteria Delta +- Added: + - 1~8 (최초 생성) + +#### Impacted Area Delta +- Docs: + - docs/runtime-flows.md: CLI 명령 흐름 변경 (REQ-019에서 처리) +- Modules: + - app/cli/commands/portfolio.py: 명령 Argument/Option 전면 교체 + - app/domain/services.py: (user_id, name) → UUID 해석 추가 + - app/repositories/postgres.py: get_portfolio_by_name_and_user() 추가 +- Tests: + - tests/test_cli.py: 소유권 검증, 오류 메시지, name 유효성 검사 케이스 추가 + +#### Delivery Trace Delta +- Batches: none (batch 미결정) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req → suggest-batch-reqs → draft-batch + +#### Validation Plan +- pytest: 소유권 검증 케이스, 오류 메시지 검증, name 유효성 검사, name 중복 변경 거부, alice/bob 독립 portfolio 생성 검증 + +### CHG-20260511-02 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Approved 전환 — confirm-req 게이트 통과 + +#### Intent +- Delivery 입력으로 사용할 수 있도록 REQ-017을 승인 상태로 승격한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - none +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - none +- Modules: + - none +- Tests: + - none + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs로 batch grouping 검토 + +#### Validation Plan +- confirm-req 게이트 재평가 결과: 필수 메타데이터, 구체적 Acceptance Criteria, Impacted Area, Change Log, blocker 없음 확인 + +### CHG-20260511-03 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-006 verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-006 verification.md의 REQ-017 AC-1~8 모두 test evidence 포함. Blocking Issues 없음. 구현 완료 상태로 전환한다. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-006 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-006) + +#### Validation Plan +- BAT-006 verification.md AC-1~8 모두 test evidence 연결 확인 (tests/test_portfolio_uuid.py, tests/test_cli.py 전체 통과) diff --git a/docs/srs/Interface/req-018_portfolio-http-api-uuid-path-param.md b/docs/srs/Interface/req-018_portfolio-http-api-uuid-path-param.md new file mode 100644 index 0000000..054ed17 --- /dev/null +++ b/docs/srs/Interface/req-018_portfolio-http-api-uuid-path-param.md @@ -0,0 +1,152 @@ +# req-018: HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- HTTP API의 portfolio 관련 path parameter를 기존 문자열 `portfolio_id`에서 UUID 기반으로 전환해 REQ-016의 스키마 변경과 일관성을 맞춘다. +- `GET /v1/assets` 응답의 `asset_id` 필드를 UUID로 반환해 trex-basic 계약을 갱신하고, dcy-002 A-3을 철회한다. + +## Requirement +- `app/api/portfolios.py`의 portfolio 관련 path parameter를 UUID 기반으로 변경한다. +- `GET /v1/assets` 응답의 `asset_id` 필드는 `portfolios.portfolio_id`(UUID)를 반환한다. +- `app/api/v1_assets.py`의 `AssetItem` 직렬화에서 `asset_id`는 `portfolio_id`(UUID) 값을 사용한다. +- `POST /v1/assets/{asset_id}/ledger/trades` 경로의 `asset_id` path parameter는 UUID를 받아 `repo.get(asset_id)`로 portfolio를 조회한다. +- dcy-002 문서의 A-3("asset_id는 portfolio_id를 그대로 사용") 조항은 이번 REQ 구현으로 공식 철회된다. `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md`에 철회 주석을 추가한다. + +## Acceptance Criteria +1. Given `GET /v1/assets`를 유효한 service-token으로 호출하면, When 응답 JSON의 `assets[].asset_id` 필드를 확인하면, Then 값이 UUID 형식(`re.match(r'^[0-9a-f-]{36}$', ...)`)이다. +2. Given UUID를 `asset_id`로 사용해 `POST /v1/assets/{asset_id}/ledger/trades`를 호출하면, When 해당 UUID의 portfolio가 존재하면, Then HTTP 201(`status="new"`) 또는 HTTP 200(`status="duplicate"`)으로 정상 처리된다. +3. Given UUID를 `asset_id`로 사용해 `POST /v1/assets/{asset_id}/ledger/trades`를 호출하면, When 해당 UUID의 portfolio가 존재하지 않으면, Then HTTP 404가 반환된다. +4. Given portfolio 관련 HTTP API path(`/portfolios/{portfolio_id}`)를 UUID로 호출하면, When 해당 UUID의 portfolio가 존재하면, Then 정상 응답이 반환된다. +5. Given `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md`를 확인하면, Then A-3 항목에 이번 REQ 구현으로 철회되었다는 주석이 추가되어 있다. + +## Impacted Area +- Docs: + - `docs/interface-contract.md` (GET /v1/assets asset_id 변경 반영 — REQ-019에서 처리) + - `docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md` (A-3 철회 주석 추가) +- Modules: + - `app/api/portfolios.py`: path parameter UUID 기반 전환 + - `app/api/v1_assets.py`: asset_id 직렬화 UUID 사용 + - `app/schemas/portfolio.py`, `app/schemas/v1.py`: asset_id/portfolio_id 필드 타입 문서화 +- Tests: + - `tests/test_app.py` 또는 `tests/test_v1.py`: GET /v1/assets asset_id UUID 형식 검증, POST /v1/assets/{asset_id}/ledger/trades UUID 호출 정상 동작 확인 + +## Notes +- trex-basic 측 코드 변경은 이번 REQ 범위 밖. portfolio-hub API 변경만 담당. +- trex-basic과 portfolio-hub는 동시에 배포한다(NFR-3 — 동시 배포 계획 기록은 REQ-019에서 `docs/interface-contract.md`에 처리). +- dcy-002 A-3 철회 주석 추가는 이 REQ 구현 시 함께 수행한다. +- `GET /v1/assets` flow의 `asset_id` 직렬화: `docs/runtime-flows.md` Flow `v1 API — GET /v1/assets` 갱신 필요 (REQ-019에서 처리). +- Source Discovery: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution (FR-4) + +## Change Log + +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- 최초 생성 — dcy-004 draft-req에 의해 생성 + +#### Intent +- dcy-004 Discovery의 FR-4(HTTP API path parameter UUID 전환, GET /v1/assets asset_id UUID 반환, dcy-002 A-3 철회) 반영 + +#### Acceptance Criteria Delta +- Added: + - 1~5 (최초 생성) + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md: GET /v1/assets asset_id 변경 반영 (REQ-019에서 처리) + - docs/discovery/dcy-002_*: A-3 철회 주석 추가 +- Modules: + - app/api/portfolios.py: path parameter UUID 전환 + - app/api/v1_assets.py: asset_id UUID 직렬화 + - app/schemas/portfolio.py, app/schemas/v1.py: 필드 타입 변경 +- Tests: + - tests/test_app.py 또는 tests/test_v1.py: GET /v1/assets asset_id UUID 형식 검증 + +#### Delivery Trace Delta +- Batches: none (batch 미결정) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req → suggest-batch-reqs → draft-batch + +#### Validation Plan +- pytest: GET /v1/assets asset_id UUID 형식 검증, POST /v1/assets/{asset_id}/ledger/trades UUID 호출 동작 검증, HTTP 404 반환 확인 + +### CHG-20260511-02 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Approved 전환 — confirm-req 게이트 통과 + +#### Intent +- Delivery 입력으로 사용할 수 있도록 REQ-018을 승인 상태로 승격한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - none +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - none +- Modules: + - none +- Tests: + - none + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs로 batch grouping 검토 + +#### Validation Plan +- confirm-req 게이트 재평가 결과: 필수 메타데이터, 구체적 Acceptance Criteria, Impacted Area, Change Log, blocker 없음 확인 + +### CHG-20260511-03 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-006 verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-006 verification.md의 REQ-018 AC-1~5 모두 evidence 포함 (AC-1~4: 구현 코드 검증, AC-5: dcy-002 A-3 철회 주석 문서 확인). Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-006 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-006); trex-basic 동시 배포 조정 필요 + +#### Validation Plan +- BAT-006 verification.md AC-1~5 evidence 연결 확인; dcy-002 A-3 철회 주석 직접 확인 완료 diff --git a/docs/srs/Migration/req-009_v1-api-db-schema-migration.md b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md index d79e16d..68447e5 100644 --- a/docs/srs/Migration/req-009_v1-api-db-schema-migration.md +++ b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md @@ -1,6 +1,6 @@ # req-009: v1 API 지원을 위한 DB 스키마 migration 추가 -- Status: Approved +- Status: Implemented - Type: Migration - Priority: High - Owner: repository-maintainers @@ -39,6 +39,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~7 모두 코드 검토 PASS. DB 환경 실행 미확인은 non-blocker로 처리됨 (revision에 IF NOT EXISTS 패턴 전체 적용 확인). Blocking Issues 없음. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001); 배포 전 Docker Compose 환경에서 실제 migration 실행 검증 권장 + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~7 코드 검토 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/Migration/req-016_portfolios-uuid-schema-migration.md b/docs/srs/Migration/req-016_portfolios-uuid-schema-migration.md new file mode 100644 index 0000000..e5fc17e --- /dev/null +++ b/docs/srs/Migration/req-016_portfolios-uuid-schema-migration.md @@ -0,0 +1,157 @@ +# req-016: portfolios 테이블 UUID 전환 및 데이터 마이그레이션 + +- Status: Implemented +- Type: Migration +- Priority: High +- Owner: repository-maintainers + +## Intent +- 현재 사용자가 직접 지정하는 TEXT PRIMARY KEY(`portfolio_id`)를 서버 생성 UUID로 교체하고, 기존 `portfolio_id` 문자열을 `name` 컬럼으로 이전한다. +- `(owner_user_id, name)` 복합 UNIQUE 제약을 DB 레벨에서 도입해 사용자 범위 내 이름 유일성을 보장한다. +- 기존 데이터와 FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)을 단일 Alembic revision으로 atomic하게 마이그레이션한다. + +## Requirement +- 단일 Alembic revision을 추가해 아래를 수행한다. + 1. `portfolios` 테이블에 `name` TEXT 컬럼을 추가한다. + 2. 기존 `portfolio_id` 값을 `name` 컬럼으로 복사한다 (변환 없이 그대로). + 3. 각 기존 레코드에 `uuid.uuid4()`로 새 UUID를 생성해 `portfolio_id` 컬럼 값을 교체한다. + 4. FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)의 `portfolio_id` 컬럼 값도 새 UUID로 함께 갱신한다. + 5. `(owner_user_id, name)` 복합 UNIQUE 제약을 `portfolios` 테이블에 추가한다. + 6. `Portfolio` dataclass(`app/domain/models.py`)의 `portfolio_id` 필드를 UUID 문자열로 유지하고 `name` 필드를 명확히 선언한다. + 7. `app/repositories/postgres.py`의 `create_portfolio()` 메서드가 서버에서 `uuid.uuid4()`로 생성된 UUID를 `portfolio_id`로 사용한다. +- 마이그레이션은 IF NOT EXISTS 패턴을 준수해 재실행 시 오류가 없어야 한다(REQ-002 준수). +- FK 참조 테이블과의 갱신은 단일 트랜잭션 내에서 atomic하게 처리한다. +- 마이그레이션 완료 후 FK 고아 레코드(orphan) 0건을 검증하는 쿼리를 revision 안에 포함한다. + +## Acceptance Criteria +1. Given 기존 `portfolio_id` 문자열로 운영 중인 portfolios 레코드가 있을 때, When 신규 Alembic revision을 `alembic upgrade head`로 실행하면, Then 각 레코드의 `name` 컬럼에 기존 `portfolio_id` 값이 그대로 복사되고, `portfolio_id`는 UUID 형식(`re.match(r'^[0-9a-f-]{36}$', ...)`)으로 교체된다. +2. Given 마이그레이션 완료 후, When `portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`의 `portfolio_id` 컬럼을 확인하면, Then 고아 레코드(FK 불일치 레코드)가 0건이다. +3. Given `(owner_user_id, name)` 복합 UNIQUE 제약이 적용된 상태에서, When 동일 `owner_user_id`와 동일 `name`으로 portfolio 레코드를 추가하면, Then DB 레벨에서 UNIQUE 제약 위반 오류가 발생한다. +4. Given 동일 `name`이지만 다른 `owner_user_id`로 portfolio 레코드를 추가하면, Then 정상 삽입된다. +5. Given 신규 Alembic revision을 이미 적용한 환경에서, When 동일 revision을 재실행하면, Then 오류 없이 완료된다(IF NOT EXISTS 준수). +6. Given `app/repositories/postgres.py`의 `create_portfolio()` 메서드가 실행되면, Then 서버에서 `uuid.uuid4()`로 생성된 UUID가 `portfolio_id`로 저장된다. + +## Impacted Area +- Docs: `docs/data-model.md` (portfolios 테이블 스키마 변경 반영 — REQ-019에서 처리) +- Modules: + - `migrations/versions/`: 신규 Alembic revision 파일 + - `app/domain/models.py`: Portfolio dataclass `portfolio_id` 및 `name` 필드 + - `app/repositories/postgres.py`: `create_portfolio()` UUID 생성, `get_portfolio_by_name_and_user()` 신규 메서드 +- Tests: + - 신규 `tests/test_portfolio_uuid.py` 또는 `tests/test_cli.py` — portfolio_id UUID 형식 검증, 동일 name 독립 생성 테스트, (owner_user_id, name) UNIQUE 제약 동작 확인 + +## Notes +- 기존 `portfolio_id` 문자열은 `name` 컬럼으로 변환 없이 복사한다(Discovery A-3 확정). +- `portfolio_id` 컬럼 타입은 TEXT 유지(PostgreSQL UUID 타입으로의 전환은 이번 범위 밖). +- `app/domain/models.py`의 `Portfolio.portfolio_id`는 UUID 문자열로 의미가 바뀌나 Python 타입(`str`)은 유지. +- `get_portfolio_by_name_and_user()` 메서드는 REQ-017(CLI 식별 방식 전환)에서도 사용한다. +- Source Discovery: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution (FR-1, FR-5, NFR-1) + +## Change Log + +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- 최초 생성 — dcy-004 draft-req에 의해 생성 + +#### Intent +- dcy-004 Discovery의 FR-1(portfolios.portfolio_id UUID 교체 및 name 컬럼 추가), FR-5(기존 데이터 마이그레이션), NFR-1(단일 revision, IF NOT EXISTS, atomic 트랜잭션) 반영 + +#### Acceptance Criteria Delta +- Added: + - 1~6 (최초 생성) + +#### Impacted Area Delta +- Docs: + - docs/data-model.md: portfolios 테이블 스키마 변경 (REQ-019에서 처리) +- Modules: + - migrations/versions/: 신규 revision 추가 + - app/domain/models.py: Portfolio dataclass 변경 + - app/repositories/postgres.py: create_portfolio UUID 생성, get_portfolio_by_name_and_user() 추가 +- Tests: + - tests/test_portfolio_uuid.py 또는 tests/test_cli.py: UUID 형식 검증, UNIQUE 제약 동작 확인 + +#### Delivery Trace Delta +- Batches: none (batch 미결정) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req → suggest-batch-reqs → draft-batch + +#### Validation Plan +- pytest: portfolio_id UUID 형식 검증, FK 고아 레코드 0건 확인, (owner_user_id, name) UNIQUE 제약 동작 확인, IF NOT EXISTS 재실행 안전성 확인 + +### CHG-20260511-02 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Approved 전환 — confirm-req 게이트 통과 + +#### Intent +- Delivery 입력으로 사용할 수 있도록 REQ-016을 승인 상태로 승격한다. + +#### Acceptance Criteria Delta +- Added: + - none +- Updated: + - none +- Removed: + - none + +#### Impacted Area Delta +- Docs: + - none +- Modules: + - none +- Tests: + - none + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs로 batch grouping 검토 + +#### Validation Plan +- confirm-req 게이트 재평가 결과: 필수 메타데이터, 구체적 Acceptance Criteria, Impacted Area, Change Log, blocker 없음 확인 + +### CHG-20260511-03 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-006 verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-006 verification.md의 REQ-016 Acceptance Mapping이 모든 AC에 대해 test evidence를 포함하고 있으며, Blocking Issues 없음으로 구현 완료 상태로 전환한다. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-006 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-006) + +#### Validation Plan +- BAT-006 verification.md AC-1~6 모두 test evidence 연결 확인 (tests/test_portfolio_uuid.py 14/14 passed, full suite 129/129 passed) diff --git a/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md b/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md index 739980d..0a4bee4 100644 --- a/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md +++ b/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md @@ -1,6 +1,6 @@ # req-010: credential_payload Fernet 암호화 및 startup validation -- Status: Approved +- Status: Implemented - Type: Non-Functional - Priority: High - Owner: repository-maintainers @@ -35,6 +35,35 @@ ## Change Log +### CHG-20260511-01 +- Date: 2026-05-11 +- Author: repository-maintainers + +#### Change Summary +- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 + +#### Intent +- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC3 DB 미확인은 REQ-009와 동일 제약으로 non-blocker 처리. + +#### Acceptance Criteria Delta +- none + +#### Impacted Area Delta +- none + +#### Delivery Trace Delta +- Batches: BAT-001 (release-candidate) +- Releases: none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: draft-release (BAT-001) + +#### Validation Plan +- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료 + ### CHG-20260502-02 - Date: 2026-05-02 - Author: repository-maintainers diff --git a/docs/srs/index.md b/docs/srs/index.md index 7703e90..84170d8 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-016` +- Current: `REQ-020` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -84,26 +84,27 @@ | REQ-001 | 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 | Testing | Approved | High | repository-maintainers | [REQ-001](./Testing/req-001_local-db-validation-scenarios.md) | | REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | Approved | High | repository-maintainers | [REQ-002](./Migration/req-002_alembic-runtime-schema-ownership.md) | | REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-003](./Documentation/req-003_local-db-operations-runbook-and-baseline-updates.md) | -| REQ-004 | GET /health 응답 스키마 확장 | Interface | Approved | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) | -| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) | -| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) | -| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) | -| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) | -| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | -| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | +| REQ-004 | GET /health 응답 스키마 확장 | Interface | Implemented | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) | +| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Implemented | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) | +| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) | +| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) | +| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) | +| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Implemented | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | +| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Implemented | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | | REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | | REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | | REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Implemented | Medium | repository-maintainers | [REQ-013](./Interface/req-013_portfolio-list-user-required.md) | | REQ-014 | CLI `portfolio create --user` 필수화 및 소유자 설정 | Interface | Implemented | Medium | repository-maintainers | [REQ-014](./Interface/req-014_portfolio-create-user-required.md) | | REQ-015 | CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) | Interface | Approved | Medium | repository-maintainers | [REQ-015](./Interface/req-015_broker-credential-cli.md) | +| REQ-016 | portfolios 테이블 UUID 전환 및 데이터 마이그레이션 | Migration | Implemented | High | repository-maintainers | [REQ-016](./Migration/req-016_portfolios-uuid-schema-migration.md) | +| REQ-017 | CLI portfolio 명령 — name+user 조합 식별 방식 전환 | Interface | Implemented | High | repository-maintainers | [REQ-017](./Interface/req-017_portfolio-cli-name-user-identification.md) | +| REQ-018 | HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 | Interface | Implemented | High | repository-maintainers | [REQ-018](./Interface/req-018_portfolio-http-api-uuid-path-param.md) | +| REQ-019 | portfolio UUID 전환 관련 baseline 문서 갱신 | Documentation | Implemented | Medium | repository-maintainers | [REQ-019](./Documentation/req-019_portfolio-uuid-baseline-docs-update.md) | ## Recent Change Log Summary -- 2026-05-03: REQ-006 CHG-20260503-01 — strategy_type 필드 추가 (StrategySet.name), BAT-001 재검증 필요 -- 2026-05-03: REQ-015 Approved 전환 — minor-change fast path (단일 저위험 REQ) -- 2026-05-03: REQ-015 Proposed 생성 — CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) -- 2026-05-02: REQ-014 Implemented 전환 — BAT-004 verification evidence 확인 (44 passed) -- 2026-05-02: REQ-014 Approved 직행 (minor-change) — CLI `portfolio create --user` 필수화 및 소유자 설정 -- 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed) -- 2026-05-02: REQ-013 Approved 전환 — CLI `portfolio list --user` 필수화 및 소유자 기반 조회 (source Discovery: dcy-003) -- 2026-05-02: REQ-012 Approved 직행 (minor-change) — service-token rotate CLI 명령 구현 \ No newline at end of file +- 2026-05-11: REQ-004~010 Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과 +- 2026-05-11: REQ-016~019 Implemented 전환 — BAT-006 verification passed, confirm-req-implemented 게이트 통과 +- 2026-05-11: REQ-016~019 Approved 전환 — confirm-req 게이트 통과 (source Discovery: dcy-004) +- 2026-05-11: REQ-016~019 Proposed 생성 — dcy-004 draft-req (portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환) +- 2026-05-03: REQ-006 CHG-20260503-01 — strategy_type 필드 추가 (StrategySet.name), BAT-001 재검증 필요 \ No newline at end of file diff --git a/migrations/versions/20260511_0015_portfolio_uuid_migration.py b/migrations/versions/20260511_0015_portfolio_uuid_migration.py new file mode 100644 index 0000000..7abdcd7 --- /dev/null +++ b/migrations/versions/20260511_0015_portfolio_uuid_migration.py @@ -0,0 +1,114 @@ +"""portfolios.portfolio_id UUID 전환 및 (owner_user_id, name) UNIQUE 제약 추가 + +Revision ID: 20260511_0015 +Revises: 20260502_0014 +Create Date: 2026-05-11 00:00:00 +""" + +from __future__ import annotations + +import uuid as _uuid + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "20260511_0015" +down_revision = "20260502_0014" +branch_labels = None +depends_on = None + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + if table_name not in inspector.get_table_names(): + return False + return any(c["name"] == column_name for c in inspector.get_columns(table_name)) + + +def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool: + if table_name not in inspector.get_table_names(): + return False + return any(c["name"] == constraint_name for c in inspector.get_unique_constraints(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + # Step 1: Add name column IF NOT EXISTS (already present via ensure_schema in most envs) + if not _column_exists(inspector, "portfolios", "name"): + op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True)) + bind.execute(sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")) + op.alter_column("portfolios", "name", nullable=False) + + # Step 2: Guard — skip UUID replacement if already migrated (UNIQUE constraint present) + already_migrated = _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name") + + if not already_migrated: + # Collect all existing portfolio_ids + result = bind.execute(sa.text("SELECT portfolio_id FROM portfolios")) + old_pids = [row[0] for row in result.fetchall()] + + for old_pid in old_pids: + new_uuid = str(_uuid.uuid4()) + # Copy old portfolio_id to name (overwrites display name with identifier) + bind.execute( + sa.text("UPDATE portfolios SET name = :old_pid WHERE portfolio_id = :old_pid"), + {"old_pid": old_pid}, + ) + # Update FK reference tables before changing portfolios PK + bind.execute( + sa.text("UPDATE portfolio_ledger SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"), + {"new_uuid": new_uuid, "old_pid": old_pid}, + ) + bind.execute( + sa.text("UPDATE portfolio_comments SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"), + {"new_uuid": new_uuid, "old_pid": old_pid}, + ) + bind.execute( + sa.text("UPDATE portfolio_tags SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"), + {"new_uuid": new_uuid, "old_pid": old_pid}, + ) + bind.execute( + sa.text("UPDATE ledger_trades SET asset_id = :new_uuid WHERE asset_id = :old_pid"), + {"new_uuid": new_uuid, "old_pid": old_pid}, + ) + # Update portfolios PK last + bind.execute( + sa.text("UPDATE portfolios SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"), + {"new_uuid": new_uuid, "old_pid": old_pid}, + ) + + # Ensure no name nulls remain before adding UNIQUE constraint + bind.execute( + sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''") + ) + + # Step 3: Add (owner_user_id, name) UNIQUE constraint + op.create_unique_constraint("uq_portfolios_owner_name", "portfolios", ["owner_user_id", "name"]) + + # Step 4: Verify no orphan records in FK reference tables + for table, col in [ + ("portfolio_ledger", "portfolio_id"), + ("portfolio_comments", "portfolio_id"), + ("portfolio_tags", "portfolio_id"), + ]: + result = bind.execute( + sa.text( + f"SELECT COUNT(*) FROM {table}" # noqa: S608 + f" WHERE {col} NOT IN (SELECT portfolio_id FROM portfolios)" + ) + ) + count = result.scalar() + if count and count > 0: + raise RuntimeError( + f"Migration 20260511_0015: {count} orphan record(s) found in {table}.{col}" + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name"): + op.drop_constraint("uq_portfolios_owner_name", "portfolios", type_="unique") diff --git a/tests/test_cli.py b/tests/test_cli.py index 83b5695..217460b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -208,12 +208,11 @@ class FakeRepo: def create_portfolio( self, - portfolio_id: str, + name: str, *, owner_user_id: str, broker: str, status: str, - name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -222,6 +221,7 @@ class FakeRepo: active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: + portfolio_id = str(uuid.uuid4()) self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, @@ -238,6 +238,13 @@ class FakeRepo: ) return self._portfolio + def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: + if self._portfolio is None: + return None + if self._portfolio.owner_user_id == owner_user_id and self._portfolio.name == name: + return self._portfolio + return None + def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: portfolio = self.get(portfolio_id) for key, value in fields.items(): @@ -484,7 +491,7 @@ def _portfolio() -> Portfolio: portfolio_id="005930:tv", broker="kis", status="enabled", - name="Samsung Electronics", + name="005930:tv", symbol="005930", cash_balance=1_000_000.0, asset_quantity=1.0, @@ -511,10 +518,14 @@ def test_cli_list_renders_table() -> None: def test_cli_status_supports_json_output() -> None: - repo = FakeRepo(_portfolio()) + repo = FakeRepo() + user = repo.create_user("alice", permission="read") + p = _portfolio() + p.owner_user_id = user.id + repo._portfolio = p runner = build_runner(repo) - result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"]) + result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv", "--user", "alice"]) assert result.exit_code == 0 payload = json.loads(result.stdout) @@ -549,6 +560,8 @@ def test_cli_create_buy_and_delete_flow() -> None: "portfolio", "buy", "005930:tv", + "--user", + "alice", "--quantity", "2", "--price", @@ -560,12 +573,12 @@ def test_cli_create_buy_and_delete_flow() -> None: ) assert repo.entries[0].action == "BUY" assert repo._portfolio.owner_user_id == user.id - delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"]) + delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--user", "alice", "--keep-ledger", "--force"]) assert create_result.exit_code == 0 assert buy_result.exit_code == 0 assert delete_result.exit_code == 0 - assert "Deleted portfolio: 005930:tv" in delete_result.stdout + assert "Deleted portfolio: 005930:tv" in delete_result.stdout # name used as identifier def test_cli_write_command_prompts_before_create() -> None: @@ -590,7 +603,7 @@ def test_cli_write_command_prompts_before_create() -> None: ) assert result.exit_code == 0 - assert "Create portfolio 005930:tv for symbol 005930?" in result.stdout + assert "Create portfolio '005930:tv' for symbol 005930 (user: alice)?" in result.stdout def test_cli_write_command_can_abort() -> None: @@ -627,8 +640,8 @@ def test_cli_portfolio_create_without_user_exits_error() -> None: ["portfolio", "create", "005930:tv", "--symbol", "005930", "--force"], ) - assert result.exit_code == 1 - assert "--user" in result.stdout + assert result.exit_code != 0 + assert "--user" in result.output def test_cli_portfolio_create_with_valid_user_sets_owner() -> None: diff --git a/tests/test_portfolio_ownership.py b/tests/test_portfolio_ownership.py index bfb4de6..333a4c0 100644 --- a/tests/test_portfolio_ownership.py +++ b/tests/test_portfolio_ownership.py @@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N response = client.post( "/portfolios", headers={"Authorization": "Bearer trade-env-token"}, - json={"portfolio_id": "new-1", "symbol": "005930"}, + json={"name": "new-1", "symbol": "005930"}, ) app.dependency_overrides.clear() diff --git a/tests/test_portfolio_uuid.py b/tests/test_portfolio_uuid.py new file mode 100644 index 0000000..e6477b6 --- /dev/null +++ b/tests/test_portfolio_uuid.py @@ -0,0 +1,563 @@ +"""Tests for BAT-006: portfolio UUID migration and name+user identification.""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone + +from typer.testing import CliRunner + +from app.cli.main import create_app +from app.core.config import Settings +from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User + + +class FakeRepo: + def __init__(self) -> None: + self._portfolios: list[Portfolio] = [] + self._users: dict[str, User] = {} + self._user_seq = 1 + self._strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:abc") + self._strategy_sets: dict[str, StrategySet] = {self._strategy_set.id: self._strategy_set} + self._entries: list[LedgerEntry] = [] + self._comments: dict[str, PortfolioComment] = {} + self._tags: dict[str, PortfolioTag] = {} + self.closed = False + + def close(self) -> None: + self.closed = True + + def ensure_schema(self) -> None: + return None + + def ping(self) -> None: + return None + + def create_user( + self, + username: str, + *, + email: str | None = None, + password_hash: str | None = None, + permission: str = "read", + is_active: bool = True, + ) -> User: + now = datetime.now(timezone.utc) + user = User( + id=f"user-{self._user_seq}", + username=username, + email=email, + permission=permission, + is_active=is_active, + password_hash=password_hash, + created_at=now, + updated_at=now, + ) + self._user_seq += 1 + self._users[user.id] = user + return user + + def get_user(self, user_id: str) -> User: + user = self._users.get(user_id) + if user is None: + raise ValueError("사용자를 찾을 수 없습니다") + return user + + def get_user_by_username(self, username: str) -> User | None: + return next((u for u in self._users.values() if u.username == username), None) + + def list_users(self) -> list[User]: + return list(self._users.values()) + + def create_portfolio( + self, + name: str, + *, + owner_user_id: str, + broker: str, + status: str, + symbol: str, + cash_balance: float, + asset_quantity: float, + asset_price: float, + profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, + ) -> Portfolio: + if any( + p.owner_user_id == owner_user_id and p.name == name + for p in self._portfolios + ): + raise ValueError(f"이미 사용 중인 이름입니다: {name}") + portfolio_id = str(uuid.uuid4()) + portfolio = Portfolio( + portfolio_id=portfolio_id, + broker=broker, + status=status, + name=name, + symbol=symbol, + owner_user_id=owner_user_id, + cash_balance=cash_balance, + asset_quantity=asset_quantity, + price=asset_price, + profit_buffer=profit_buffer, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, + ) + self._portfolios.append(portfolio) + return portfolio + + def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: + return next( + (p for p in self._portfolios if p.owner_user_id == owner_user_id and p.name == name), + None, + ) + + def get(self, portfolio_id: str) -> Portfolio: + portfolio = next((p for p in self._portfolios if p.portfolio_id == portfolio_id), None) + if portfolio is None: + raise ValueError("포트폴리오를 찾을 수 없습니다") + return portfolio + + def get_or_create( + self, + portfolio_id: str, + symbol: str, + *, + name: str | None = None, + broker: str = "kis", + status: str = "enabled", + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, + owner_user_id: str | None = None, + initial_cash: float = 0.0, + ) -> Portfolio: + existing = next((p for p in self._portfolios if p.portfolio_id == portfolio_id), None) + if existing: + return existing + portfolio = Portfolio( + portfolio_id=portfolio_id, + broker=broker, + status=status, + name=name or symbol, + symbol=symbol, + owner_user_id=owner_user_id, + cash_balance=initial_cash, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, + ) + self._portfolios.append(portfolio) + return portfolio + + def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: + portfolio = self.get(portfolio_id) + for key, value in fields.items(): + if value is not None: + setattr(portfolio, key, value) + return portfolio + + def list_portfolios( + self, + *, + broker: str | None = None, + status: str | None = None, + owner_user_id: str | None = None, + include_tags: list[str] | None = None, + exclude_tags: list[str] | None = None, + match_mode: str | None = None, + ) -> list[Portfolio]: + results = self._portfolios + if broker is not None: + results = [p for p in results if p.broker == broker] + if status is not None: + results = [p for p in results if p.status == status] + if owner_user_id is not None: + results = [p for p in results if p.owner_user_id == owner_user_id] + return results + + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: + s = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:abc") + self._strategy_set = s + self._strategy_sets[s.id] = s + return s + + def get_strategy_set(self, id: str) -> StrategySet | None: + return self._strategy_sets.get(id) + + def list_strategy_sets(self) -> list[StrategySet]: + return list(self._strategy_sets.values()) + + def delete_strategy_set(self, id: str) -> None: + self._strategy_sets.pop(id, None) + + def save( + self, + portfolio: Portfolio, + *, + current_price: float, + action: str, + trade_price: float, + quantity: float, + fee_amount: float, + tax_amount: float, + total_value_after: float, + strategy_set_id: str = "manual", + note: str = "", + ) -> None: + idx = next((i for i, p in enumerate(self._portfolios) if p.portfolio_id == portfolio.portfolio_id), None) + if idx is not None: + self._portfolios[idx] = portfolio + self._entries.insert( + 0, + LedgerEntry( + portfolio_id=portfolio.portfolio_id, + recorded_at=datetime.now(timezone.utc), + action=action, + price=trade_price, + quantity=quantity, + fee_amount=fee_amount, + tax_amount=tax_amount, + cash_balance_after=portfolio.cash_balance, + asset_quantity_after=portfolio.asset_quantity, + total_value_after=total_value_after, + profit_buffer_after=portfolio.profit_buffer, + strategy_set_id=strategy_set_id, + note=note, + ), + ) + + def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: + return [e for e in self._entries if e.portfolio_id == portfolio_id][:limit] + + def reset(self, portfolio_id: str) -> Portfolio: + portfolio = self.get(portfolio_id) + portfolio.cash_balance = 0.0 + portfolio.asset_quantity = 0.0 + portfolio.price = 0.0 + portfolio.profit_buffer = 0.0 + return portfolio + + def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: + portfolio = self.get(portfolio_id) + if action == "INVEST": + portfolio.profit_buffer += amount + else: + portfolio.profit_buffer -= amount + return portfolio + + def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: + portfolio = self.get(portfolio_id) + if direction == "to-buffer": + portfolio.cash_balance -= amount + portfolio.profit_buffer += amount + else: + portfolio.cash_balance += amount + portfolio.profit_buffer -= amount + return portfolio + + def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: + self._portfolios = [p for p in self._portfolios if p.portfolio_id != portfolio_id] + if clear_ledger: + self._entries = [e for e in self._entries if e.portfolio_id != portfolio_id] + + def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: + key = f"{portfolio_id}:{tag}" + if key in self._tags: + raise ValueError(f"이미 연결된 태그입니다: {tag}") + item = PortfolioTag(portfolio_id=portfolio_id, tag=tag, created_at=datetime.now(timezone.utc)) + self._tags[key] = item + return item + + def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: + return [t for t in self._tags.values() if t.portfolio_id == portfolio_id] + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + key = f"{portfolio_id}:{tag}" + if key not in self._tags: + raise ValueError("태그를 찾을 수 없습니다") + del self._tags[key] + + def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment: + self.get(portfolio_id) + now = datetime.now(timezone.utc) + comment = PortfolioComment( + id=str(uuid.uuid4()), + portfolio_id=portfolio_id, + content=content, + created_at=now, + updated_at=now, + ) + self._comments[comment.id] = comment + return comment + + def list_comments(self, portfolio_id: str) -> list[PortfolioComment]: + self.get(portfolio_id) + return [c for c in self._comments.values() if c.portfolio_id == portfolio_id] + + def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: + self.get(portfolio_id) + comment = self._comments.get(comment_id) + if comment is None: + raise ValueError("댓글을 찾을 수 없습니다") + comment.content = content + comment.updated_at = datetime.now(timezone.utc) + return comment + + def delete_comment(self, portfolio_id: str, comment_id: str) -> None: + self.get(portfolio_id) + if comment_id not in self._comments: + raise ValueError("댓글을 찾을 수 없습니다") + del self._comments[comment_id] + + +def _settings() -> Settings: + return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000") + + +def build_runner(repo: FakeRepo) -> CliRunner: + app = create_app(settings_factory=_settings, repository_factory=lambda settings: repo) + runner = CliRunner() + runner.app = app + return runner + + +# --- REQ-016: portfolio_id is a server-generated UUID --- + +def test_create_portfolio_assigns_uuid_id() -> None: + repo = FakeRepo() + alice = repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "alice", "--force"], + ) + + assert result.exit_code == 0, result.output + assert len(repo._portfolios) == 1 + portfolio = repo._portfolios[0] + assert portfolio.name == "SAMSUNG" + assert portfolio.owner_user_id == alice.id + try: + uuid.UUID(portfolio.portfolio_id) + except ValueError: + raise AssertionError(f"portfolio_id is not a valid UUID: {portfolio.portfolio_id!r}") + + +def test_create_returns_uuid_in_response() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--symbol", "005930", "--user", "alice", "--force"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + portfolio_id = payload["portfolio"]["portfolio_id"] + uuid.UUID(portfolio_id) # must not raise + + +# --- REQ-017: CLI name+user identification --- + +def test_status_resolves_by_name_and_user() -> None: + repo = FakeRepo() + alice = repo.create_user("alice", permission="read") + runner = build_runner(repo) + + create_result = runner.invoke( + runner.app, + ["--format", "json", "portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "alice", "--force"], + ) + assert create_result.exit_code == 0, create_result.output + + status_result = runner.invoke( + runner.app, + ["--format", "json", "portfolio", "status", "MY_PORT", "--user", "alice"], + ) + + assert status_result.exit_code == 0, status_result.output + payload = json.loads(status_result.stdout) + assert payload["portfolio"]["name"] == "MY_PORT" + assert payload["portfolio"]["owner_user_id"] == alice.id + + +def test_status_fails_when_user_not_found() -> None: + repo = FakeRepo() + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "status", "MY_PORT", "--user", "ghost"], + ) + + assert result.exit_code == 1 + assert "ghost" in result.output + + +def test_status_fails_when_portfolio_name_not_found_for_user() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "status", "NONEXISTENT", "--user", "alice"], + ) + + assert result.exit_code == 1 + assert "NONEXISTENT" in result.output + + +def test_create_without_user_fails() -> None: + repo = FakeRepo() + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "create", "MY_PORT", "--symbol", "005930", "--force"], + ) + + assert result.exit_code != 0 + assert "--user" in result.output + + +def test_create_with_unknown_user_fails() -> None: + repo = FakeRepo() + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "ghost", "--force"], + ) + + assert result.exit_code == 1 + assert "ghost" in result.output + + +def test_name_validation_rejects_invalid_characters() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "create", "invalid name!", "--symbol", "005930", "--user", "alice", "--force"], + ) + + assert result.exit_code == 1 + assert "invalid name!" in result.output or "유효하지 않은" in result.output + + +def test_name_validation_rejects_empty_name() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["portfolio", "create", "", "--symbol", "005930", "--user", "alice", "--force"], + ) + + assert result.exit_code != 0 + + +def test_alice_and_bob_can_have_same_portfolio_name() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + repo.create_user("bob", permission="read") + runner = build_runner(repo) + + alice_result = runner.invoke( + runner.app, + ["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "alice", "--force"], + ) + bob_result = runner.invoke( + runner.app, + ["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "bob", "--force"], + ) + + assert alice_result.exit_code == 0, alice_result.output + assert bob_result.exit_code == 0, bob_result.output + assert len(repo._portfolios) == 2 + portfolio_ids = {p.portfolio_id for p in repo._portfolios} + assert len(portfolio_ids) == 2, "Alice and Bob must have distinct portfolio UUIDs" + + +def test_buy_resolves_portfolio_by_name_and_user() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + runner.invoke( + runner.app, + ["portfolio", "create", "MY_PORT", "--symbol", "005930", "--cash-balance", "1000000", "--user", "alice", "--force"], + ) + buy_result = runner.invoke( + runner.app, + ["portfolio", "buy", "MY_PORT", "--user", "alice", "--quantity", "1", "--price", "70000", "--force"], + ) + + assert buy_result.exit_code == 0, buy_result.output + assert len(repo._entries) == 1 + assert repo._entries[0].action == "BUY" + + +def test_delete_resolves_portfolio_by_name_and_user() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + runner.invoke( + runner.app, + ["portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "alice", "--force"], + ) + delete_result = runner.invoke( + runner.app, + ["portfolio", "delete", "MY_PORT", "--user", "alice", "--force"], + ) + + assert delete_result.exit_code == 0, delete_result.output + assert "MY_PORT" in delete_result.output + assert len(repo._portfolios) == 0 + + +def test_alice_cannot_delete_bobs_portfolio() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + repo.create_user("bob", permission="read") + runner = build_runner(repo) + + runner.invoke( + runner.app, + ["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "bob", "--force"], + ) + delete_result = runner.invoke( + runner.app, + ["portfolio", "delete", "SAMSUNG", "--user", "alice", "--force"], + ) + + assert delete_result.exit_code == 1 + assert len(repo._portfolios) == 1, "Bob's portfolio must not be deleted" + + +def test_update_new_name_renames_portfolio() -> None: + repo = FakeRepo() + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + runner.invoke( + runner.app, + ["portfolio", "create", "OLD_NAME", "--symbol", "005930", "--user", "alice", "--force"], + ) + rename_result = runner.invoke( + runner.app, + ["portfolio", "update", "OLD_NAME", "--user", "alice", "--new-name", "NEW_NAME", "--force"], + ) + + assert rename_result.exit_code == 0, rename_result.output + portfolio = repo._portfolios[0] + assert portfolio.name == "NEW_NAME"