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
This commit is contained in:
2026-05-11 09:27:57 +00:00
parent 9ed307555a
commit 829629fa8f
46 changed files with 3260 additions and 220 deletions
+88
View File
@@ -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`. 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 ## Docs Sync Guardrail
To keep implementation and specification documents in sync, this repository provides an automated check. To keep implementation and specification documents in sync, this repository provides an automated check.
+122 -79
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import Any from typing import Any
import typer import typer
@@ -22,6 +23,7 @@ from app.schemas.portfolio import (
) )
from app.services.portfolio_service import PortfolioService 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.") portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
comment_app = typer.Typer(help="Portfolio comment history 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) 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") @portfolio_app.command("list")
def list_portfolios( def list_portfolios(
ctx: typer.Context, ctx: typer.Context,
@@ -105,22 +126,25 @@ def list_portfolios(
@portfolio_app.command("status") @portfolio_app.command("status")
def portfolio_status( def portfolio_status(
ctx: typer.Context, 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."), recent: int = typer.Option(5, "--recent", min=1, help="Number of recent ledger entries."),
) -> None: ) -> None:
detail = _with_service( _validate_portfolio_name(name, ctx.obj.console)
ctx,
lambda service, app_context: service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent), 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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("create") @portfolio_app.command("create")
def create_portfolio( def create_portfolio(
ctx: typer.Context, ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."),
user: str | None = typer.Option(None, "--user", help="Owner username (required)."), user: str = typer.Option(..., "--user", help="Owner username (required)."),
name: str | None = typer.Option(None, help="Asset display name."),
symbol: str = typer.Option(..., help="Trading symbol."), symbol: str = typer.Option(..., help="Trading symbol."),
broker: str = typer.Option("kis", help="Broker code."), broker: str = typer.Option("kis", help="Broker code."),
status: str = typer.Option("enabled", help="Portfolio status."), 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."), strategy_params: str = typer.Option("{}", help="Strategy params as JSON object."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
if user is None: _validate_portfolio_name(name, ctx.obj.console)
ctx.obj.console.print("Error: --user 옵션은 필수입니다.") _confirm(force, f"Create portfolio {name!r} for symbol {symbol} (user: {user})?")
raise typer.Exit(code=1)
_confirm(force, f"Create portfolio {portfolio_id} for symbol {symbol}?")
def _callback(service, repository, app_context): def _callback(service, repository, app_context):
owner = repository.get_user_by_username(user) owner = repository.get_user_by_username(user)
@@ -143,7 +165,6 @@ def create_portfolio(
raise ValueError(f"사용자를 찾을 수 없습니다: {user}") raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
return service.create_portfolio( return service.create_portfolio(
CreatePortfolioRequest( CreatePortfolioRequest(
portfolio_id=portfolio_id,
name=name, name=name,
symbol=symbol, symbol=symbol,
broker=broker, broker=broker,
@@ -165,40 +186,50 @@ def create_portfolio(
@portfolio_app.command("update") @portfolio_app.command("update")
def update_portfolio( def update_portfolio(
ctx: typer.Context, 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."), broker: str | None = typer.Option(None, help="Broker code."),
status: str | None = typer.Option(None, help="Portfolio status."), 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."), symbol: str | None = typer.Option(None, help="Trading symbol."),
strategy_name: str | None = typer.Option(None, help="Strategy name."), strategy_name: str | None = typer.Option(None, help="Strategy name."),
strategy_params: str | None = typer.Option(None, help="Strategy params as JSON object."), 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."), asset_price: float | None = typer.Option(None, min=0.0, help="Current asset price."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> 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) parsed_strategy_params = None if strategy_params is None else _parse_strategy_params(strategy_params)
detail = _with_service(
ctx, def _callback(service, repository, app_context):
lambda service, app_context: service.update_portfolio( portfolio_id = _resolve_portfolio_id(repository, name, user)
return service.update_portfolio(
portfolio_id, portfolio_id,
UpdatePortfolioRequest( UpdatePortfolioRequest(
broker=broker, broker=broker,
status=status, status=status,
name=name, name=new_name,
symbol=symbol, symbol=symbol,
strategy_name=strategy_name, strategy_name=strategy_name,
strategy_params=parsed_strategy_params, strategy_params=parsed_strategy_params,
asset_price=asset_price, asset_price=asset_price,
), ),
), )
)
detail = _with_service_and_repo(ctx, _callback)
render_output(ctx.obj.console, ctx.obj.output_format, detail) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("buy") @portfolio_app.command("buy")
def buy_portfolio( def buy_portfolio(
ctx: typer.Context, 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."), quantity: float = typer.Option(..., min=0.0000001, help="Quantity to buy."),
price: float = typer.Option(..., min=0.0000001, help="Execution price."), price: float = typer.Option(..., min=0.0000001, help="Execution price."),
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."), 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."), note: str = typer.Option("", help="Ledger note."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_validate_portfolio_name(name, ctx.obj.console)
total_cost = quantity * price + fee + tax total_cost = quantity * price + fee + tax
_confirm(force, f"Buy {quantity:g} units of {portfolio_id} at {price:,.0f} (total {total_cost:,.0f})?") _confirm(force, f"Buy {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (total {total_cost:,.0f})?")
detail = _with_service(
ctx, def _callback(service, repository, app_context):
lambda service, app_context: service.buy( portfolio_id = _resolve_portfolio_id(repository, name, user)
portfolio_id, return service.buy(portfolio_id, BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note))
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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("sell") @portfolio_app.command("sell")
def sell_portfolio( def sell_portfolio(
ctx: typer.Context, 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."), quantity: float = typer.Option(..., min=0.0000001, help="Quantity to sell."),
price: float = typer.Option(..., min=0.0000001, help="Execution price."), price: float = typer.Option(..., min=0.0000001, help="Execution price."),
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."), 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."), note: str = typer.Option("", help="Ledger note."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_validate_portfolio_name(name, ctx.obj.console)
net_amount = quantity * price - fee - tax net_amount = quantity * price - fee - tax
_confirm(force, f"Sell {quantity:g} units of {portfolio_id} at {price:,.0f} (net {net_amount:,.0f})?") _confirm(force, f"Sell {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (net {net_amount:,.0f})?")
detail = _with_service(
ctx, def _callback(service, repository, app_context):
lambda service, app_context: service.sell( portfolio_id = _resolve_portfolio_id(repository, name, user)
portfolio_id, return service.sell(portfolio_id, SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note))
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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("invest") @portfolio_app.command("invest")
def invest_portfolio( def invest_portfolio(
ctx: typer.Context, 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."), movement_type: str = typer.Option(..., "--type", help="deposit, profit, or withdraw."),
amount: float = typer.Option(..., min=0.0000001, help="Buffer adjustment amount."), amount: float = typer.Option(..., min=0.0000001, help="Buffer adjustment amount."),
note: str = typer.Option("", help="Ledger note."), note: str = typer.Option("", help="Ledger note."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {portfolio_id}?") _validate_portfolio_name(name, ctx.obj.console)
detail = _with_service( _confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {name!r} (user: {user})?")
ctx,
lambda service, app_context: service.invest( def _callback(service, repository, app_context):
portfolio_id, portfolio_id = _resolve_portfolio_id(repository, name, user)
InvestRequest(movement_type=movement_type, amount=amount, note=note), 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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("transfer") @portfolio_app.command("transfer")
def transfer_portfolio( def transfer_portfolio(
ctx: typer.Context, 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."), direction: str = typer.Option(..., help="to-buffer or from-buffer."),
amount: float = typer.Option(..., min=0.0000001, help="Transfer amount."), amount: float = typer.Option(..., min=0.0000001, help="Transfer amount."),
note: str = typer.Option("", help="Ledger note."), note: str = typer.Option("", help="Ledger note."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {portfolio_id}?") _validate_portfolio_name(name, ctx.obj.console)
detail = _with_service( _confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {name!r} (user: {user})?")
ctx,
lambda service, app_context: service.transfer( def _callback(service, repository, app_context):
portfolio_id, portfolio_id = _resolve_portfolio_id(repository, name, user)
TransferRequest(direction=direction, amount=amount, note=note), 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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("reset") @portfolio_app.command("reset")
def reset_portfolio( def reset_portfolio(
ctx: typer.Context, 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."), 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."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_confirm(force, f"Reset portfolio {portfolio_id}?") _validate_portfolio_name(name, ctx.obj.console)
detail = _with_service( _confirm(force, f"Reset portfolio {name!r} (user: {user})?")
ctx,
lambda service, app_context: service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol)), 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) render_output(ctx.obj.console, ctx.obj.output_format, detail)
@portfolio_app.command("delete") @portfolio_app.command("delete")
def delete_portfolio( def delete_portfolio(
ctx: typer.Context, 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."), keep_ledger: bool = typer.Option(False, help="Preserve ledger history."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
_validate_portfolio_name(name, ctx.obj.console)
suffix = " and keep ledger" if keep_ledger else "" suffix = " and keep ledger" if keep_ledger else ""
_confirm(force, f"Delete portfolio {portfolio_id}{suffix}?") _confirm(force, f"Delete portfolio {name!r} (user: {user}){suffix}?")
_with_service(
ctx, def _callback(service, repository, app_context):
lambda service, app_context: service.delete(portfolio_id, keep_ledger=keep_ledger), 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": 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: else:
ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}") ctx.obj.console.print(f"Deleted portfolio: {name}")
@comment_app.command("add") @comment_app.command("add")
def comment_add( def comment_add(
ctx: typer.Context, 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)."), content: str = typer.Option(..., "--content", help="Comment text (supports multiline)."),
) -> None: ) -> None:
detail = _with_service( detail = _with_service(
@@ -334,7 +377,7 @@ def comment_add(
@comment_app.command("list") @comment_app.command("list")
def comment_list( def comment_list(
ctx: typer.Context, ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
) -> None: ) -> None:
detail = _with_service( detail = _with_service(
ctx, ctx,
@@ -346,7 +389,7 @@ def comment_list(
@comment_app.command("update") @comment_app.command("update")
def comment_update( def comment_update(
ctx: typer.Context, 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."), comment_id: str = typer.Argument(..., help="Comment identifier."),
content: str = typer.Option(..., "--content", help="Updated comment text."), content: str = typer.Option(..., "--content", help="Updated comment text."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
@@ -366,7 +409,7 @@ def comment_update(
@comment_app.command("delete") @comment_app.command("delete")
def comment_delete( def comment_delete(
ctx: typer.Context, 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."), comment_id: str = typer.Argument(..., help="Comment identifier."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
@@ -384,7 +427,7 @@ def comment_delete(
@tag_app.command("add") @tag_app.command("add")
def tag_add( def tag_add(
ctx: typer.Context, 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."), tag: str = typer.Option(..., "--tag", help="Tag value."),
) -> None: ) -> None:
detail = _with_service( detail = _with_service(
@@ -397,7 +440,7 @@ def tag_add(
@tag_app.command("list") @tag_app.command("list")
def tag_list( def tag_list(
ctx: typer.Context, ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
) -> None: ) -> None:
detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id)) 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.") 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") @tag_app.command("remove")
def tag_remove( def tag_remove(
ctx: typer.Context, 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."), tag: str = typer.Option(..., "--tag", help="Tag value."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
+2 -2
View File
@@ -39,12 +39,11 @@ class PortfolioDomainRepository(Protocol):
) -> Portfolio: ... ) -> Portfolio: ...
def create_portfolio( def create_portfolio(
self, self,
portfolio_id: str, name: str,
*, *,
owner_user_id: str, owner_user_id: str,
broker: str, broker: str,
status: str, status: str,
name: str,
symbol: str, symbol: str,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
@@ -53,6 +52,7 @@ class PortfolioDomainRepository(Protocol):
active_strategy_id: str | None, active_strategy_id: str | None,
avabile_strategies: list[str] | None, avabile_strategies: list[str] | None,
) -> Portfolio: ... ) -> 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 update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
def list_portfolios( def list_portfolios(
self, self,
+84 -47
View File
@@ -198,6 +198,21 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT") 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("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") 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( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS portfolio_ledger ( CREATE TABLE IF NOT EXISTS portfolio_ledger (
@@ -717,12 +732,11 @@ class PostgresPortfolioRepository:
def create_portfolio( def create_portfolio(
self, self,
portfolio_id: str, name: str,
*, *,
owner_user_id: str, owner_user_id: str,
broker: str, broker: str,
status: str, status: str,
name: str,
symbol: str, symbol: str,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
@@ -731,42 +745,58 @@ class PostgresPortfolioRepository:
active_strategy_id: str | None, active_strategy_id: str | None,
avabile_strategies: list[str] | None, avabile_strategies: list[str] | None,
) -> Portfolio: ) -> 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() self.ensure_schema()
with self._connect().cursor() as cur: 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( cur.execute(
""" _SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
INSERT INTO portfolios ( (owner_user_id, name),
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() row = cur.fetchone()
portfolio = self._row_to_portfolio(row) if row is None:
portfolio.tags = [] return None
return portfolio return self._row_to_portfolio(row)
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema() self.ensure_schema()
@@ -794,20 +824,27 @@ class PostgresPortfolioRepository:
return self.get(portfolio_id) return self.get(portfolio_id)
assignments.append("updated_at = NOW()") assignments.append("updated_at = NOW()")
with self._connect().cursor() as cur: try:
cur.execute( with self._connect().cursor() as cur:
f""" cur.execute(
UPDATE portfolios f"""
SET {', '.join(assignments)} UPDATE portfolios
WHERE portfolio_id = %s SET {', '.join(assignments)}
RETURNING WHERE portfolio_id = %s
portfolio_id, broker, status, name, symbol, RETURNING
cash_balance, asset_quantity, asset_price, profit_buffer, portfolio_id, broker, status, name, symbol,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at 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), """,
) (*params, portfolio_id),
row = cur.fetchone() )
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) return self._row_to_portfolio(row)
def list_portfolios( def list_portfolios(
+14 -1
View File
@@ -5,8 +5,12 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
import re as _re
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
_PORTFOLIO_NAME_RE = _re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")
class StrategySetResponse(BaseModel): class StrategySetResponse(BaseModel):
id: str id: str
@@ -70,7 +74,7 @@ class PortfolioDetailResponse(BaseModel):
class CreatePortfolioRequest(PortfolioBase): 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_name: str = "target_value"
strategy_params: dict[str, Any] = Field(default_factory=dict) strategy_params: dict[str, Any] = Field(default_factory=dict)
active_strategy_id: str | None = None active_strategy_id: str | None = None
@@ -80,6 +84,15 @@ class CreatePortfolioRequest(PortfolioBase):
asset_price: float = Field(default=0.0, ge=0.0) asset_price: float = Field(default=0.0, ge=0.0)
profit_buffer: 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): class UpdatePortfolioRequest(BaseModel):
broker: str | None = None broker: str | None = None
+1 -2
View File
@@ -214,11 +214,10 @@ class PortfolioService:
active_strategy_id = strategy_set.id active_strategy_id = strategy_set.id
avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies) avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies)
portfolio = self._repository.create_portfolio( portfolio = self._repository.create_portfolio(
payload.portfolio_id, payload.name,
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
broker=payload.broker, broker=payload.broker,
status=payload.status, status=payload.status,
name=payload.name or payload.symbol,
symbol=payload.symbol, symbol=payload.symbol,
cash_balance=payload.cash_balance, cash_balance=payload.cash_balance,
asset_quantity=payload.asset_quantity, asset_quantity=payload.asset_quantity,
@@ -1,6 +1,6 @@
# BAT-001 Index # BAT-001 Index
- Status: in-delivery - Status: release-candidate
- Batch: BAT-001 - Batch: BAT-001
- Profile: standard - Profile: standard
- Created: 2026-05-02 - Created: 2026-05-02
@@ -61,7 +61,7 @@
| Stage | File | Status | | Stage | File | Status |
|-------|------|--------| |-------|------|--------|
| Planning | [planning.md](./planning.md) | draft | | Planning | [planning.md](./planning.md) | confirmed |
| Design | [design.md](./design.md) | draft | | Design | [design.md](./design.md) | confirmed |
| Implementation | [implementation.md](./implementation.md) | draft | | Implementation | [implementation.md](./implementation.md) | completed |
| Verification | [verification.md](./verification.md) | draft | | Verification | [verification.md](./verification.md) | re-verified |
@@ -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는 `<name>` + `--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 <subcommand> <portfolio_id>` → `portfolio <subcommand> <name> --user <username>`
- 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: `<name>` + `--user` → `get_portfolio_by_name_and_user()` → UUID 조회 흐름 추가
- `v1 API — GET /v1/assets` flow: `asset_id = portfolio_id (UUID)` 반환 명시
@@ -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` → `<name> --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 `<name> --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
@@ -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 뮤테이션 명령을 `<name>` + `--user <username>` 조합 식별 방식으로 전환하고, 내부에서 `(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 |
@@ -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 → `<name>` + `--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 `<name>` + `--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 검증 포함(단순 재실행 확인).
@@ -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 <name> --user <username>` 실행 시 portfolio_id를 사용자가 직접 지정하지 않고 UUID가 DB에 저장됨
- Evidence: `test_create_portfolio_assigns_uuid_id` — create 후 `uuid.UUID(portfolio.portfolio_id)` 검증 ✓
- AC-2: `portfolio buy <name> --user alice` 실행 시 `(alice, <name>)` 단일 쿼리 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 `<name>` + `--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 형식 변경)
@@ -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 |
@@ -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 검토 필요.
@@ -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 |
@@ -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)과 맞지 않으면 수동 검증 절차 수정 필요
@@ -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 환경에서 확인 권장.
@@ -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 |
@@ -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에서 최초 작성됨. 이후 구현 변경 시 해당 흐름도 갱신 필요 (현재 기준 코드와 일치 확인됨).
@@ -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 |
@@ -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단계 현황 파악에서 확인 후 통합 방식 결정
- 실제 구현 코드와 문서 내용 불일치 시 코드 기준으로 작성
@@ -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를 갱신함.
+3
View File
@@ -13,3 +13,6 @@
| 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-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-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/) | | 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/) |
+101 -5
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-04-30 22:13 - Last Updated (KST): 2026-05-11 00:00
- Source Discovery / Batch: bootstrap-baseline - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008
## Project Summary ## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. - 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 - 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 - 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` ## Entity: `Portfolio`
@@ -38,22 +41,29 @@
### Key Fields ### 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 - 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 - owner_user_id :: text|null :: owning user reference for attribution and authorization
- active_strategy_id :: text|null :: currently selected strategy-set reference - active_strategy_id :: text|null :: currently selected strategy-set reference
- cash_balance :: double precision :: current cash snapshot after the latest ledger event - cash_balance :: double precision :: current cash snapshot after the latest ledger event
- asset_quantity :: double precision :: current holdings 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 ### Relationships
- User :: owner_user_id links a portfolio to its owning user when ownership is assigned - 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 - 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 ### 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. - 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. - 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. - 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. - 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 ## Shared Consistency Rules
- auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run. - 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. - 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. - 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 ## Persistence / Integration Notes
@@ -110,4 +204,6 @@
## Change Log ## 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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
@@ -141,6 +141,7 @@
- A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항) - A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항)
- A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항) - A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항)
- A-3: `GET /v1/assets`의 `asset_id`는 기존 `portfolio_id`를 그대로 사용한다. 별도 asset 식별자 체계를 만들지 않는다. - 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-4: `trading_enabled`는 현재 단계에서 `users.is_active`와 연동하는 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope다.
- A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다. - A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다.
@@ -1,14 +1,18 @@
# 0. 문서 상태 # 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. 이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: draft - 상태: confirmed
- 문서 ID: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution - 문서 ID: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution
- 이슈명: portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 - 이슈명: portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환
- 작성 시각(KST): 2026-05-11 00:00 - 작성 시각(KST): 2026-05-11 00:00
- 마지막 갱신 시각(KST): 2026-05-11 00:00 - 마지막 갱신 시각(KST): 2026-05-11 (draft-req 완료 — REQ-016~019 생성)
- 대체됨: 없음 - 대체됨: 없음
- 후속 Discovery 참조: 없음 - 후속 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. 계획 상태 요약 # 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
@@ -50,7 +54,7 @@
- 제약사항 - 제약사항
- 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수). - 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수).
- dcy-002 A-3("asset_id는 portfolio_id를 그대로 사용")은 이번 Discovery에서 철회된다. trex-basic 팀에게 breaking change를 사전 통보해야 한다. - 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-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-2: `portfolio create` CLI 명령은 `name` Argument와 `--user` 옵션을 필수로 받고, `portfolio_id`는 서버가 UUID로 발급한다. 사용자는 UUID를 직접 지정하거나 조회할 수 없다.
- FR-3: CLI 뮤테이션 명령(`status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update`)은 `<name>` Argument와 `--user <username>` 옵션으로 portfolio를 식별한다. 내부에서 `(owner_user_id, name)` → `portfolio_id(UUID)`를 조회한 뒤 처리한다. `name` + `--user` 조합으로 portfolio를 찾지 못하면 명확한 오류를 반환한다. - FR-3: CLI 뮤테이션 명령(`status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update`)은 `<name>` Argument와 `--user <username>` 옵션으로 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-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-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 흐름 변경 반영). - 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-1: 데이터 마이그레이션은 단일 Alembic revision으로 수행하며, IF NOT EXISTS 패턴을 준수해 재실행 시 오류가 없어야 한다(REQ-002 준수). FK 참조 테이블과의 갱신은 트랜잭션 내에서 atomic하게 처리한다.
- NFR-2: CLI `(owner_user_id, name)` → UUID 조회는 단일 DB 쿼리로 처리하며, 조회 실패 시 명확한 오류 메시지(`포트폴리오를 찾을 수 없습니다: {name} (user: {username})`)를 출력해야 한다. - 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 - Out of Scope
- trex-basic 측 코드 변경 — portfolio-hub API 변경만 담당 - trex-basic 측 코드 변경 — portfolio-hub API 변경만 담당
- `name` 변경 이력 감사 로그 — 향후 반복으로 Deferred - `name` 변경 이력 감사 로그 — 향후 반복으로 Deferred
- `name` 허용 문자 유효성 검사 정책 — REQ 단계에서 결정 - `name` 허용 문자 유효성 검사 정책 — 확정됨. `[a-zA-Z0-9._:-]` 허용, 공백 불허, 1~100자, 대소문자 구분 (2026-05-11)
- HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 — {{OUT_OF_SCOPE_API_NAME_LOOKUP}} 결정 필요 - HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가 — 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회 (OQ-3 Answered 2026-05-11)
# 6. 리스크/가정 목록 # 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. 불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
@@ -132,9 +137,9 @@
- R-1 (High): 기존 FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)의 portfolio_id를 새 UUID로 일괄 갱신하는 마이그레이션 중 오류 발생 시 데이터 불일치가 생길 수 있다. - R-1 (High): 기존 FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)의 portfolio_id를 새 UUID로 일괄 갱신하는 마이그레이션 중 오류 발생 시 데이터 불일치가 생길 수 있다.
- 완화 방안: 마이그레이션 전 DB 전체 백업 수행. 마이그레이션 스크립트를 단일 트랜잭션으로 작성하고, 검증 쿼리(FK 고아 레코드 0건 확인)를 마이그레이션 스크립트 마지막에 실행한다. - 완화 방안: 마이그레이션 전 DB 전체 백업 수행. 마이그레이션 스크립트를 단일 트랜잭션으로 작성하고, 검증 쿼리(FK 고아 레코드 0건 확인)를 마이그레이션 스크립트 마지막에 실행한다.
- R-2 (High): `GET /v1/assets` `asset_id`가 UUID로 바뀌면 trex-basic 데몬의 `POST /v1/assets/{asset_id}/ledger/trades` 경로가 즉시 동작하지 않는다. 배포 타이밍 불일치 시 운영 장애가 발생한다. - 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). - 완화 방안: trex-basic과 portfolio-hub를 동시에 배포한다(OQ-1 Answered). 임시 호환 레이어 불필요. 두 저장소의 배포 파이프라인에서 릴리즈 타이밍을 조율한다.
- R-3 (Medium): `name`을 mutable로 허용하면 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨진다. - R-3 (Medium): `name`이 mutable이므로 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨질 수 있다.
- 완화 방안: name을 immutable(생성 후 변경 불가)로 정책을 확정하거나, 변경 시 경고와 함께 명시적 확인 절차를 요구한다. 결정은 OQ-2에서 처리한다. - 완화 방안: 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-1: `portfolio_id`(UUID)는 서버에서 `uuid.uuid4()`로 생성하며, 사용자가 직접 지정하거나 예측할 수 없다.
- A-2: `name`은 `(owner_user_id, name)` 복합 UNIQUE 제약을 만족하면 되며, 전역 유일성은 요구하지 않는다. - A-2: `name`은 `(owner_user_id, name)` 복합 UNIQUE 제약을 만족하면 되며, 전역 유일성은 요구하지 않는다.
- A-3: 기존 `portfolio_id` 문자열(`005930:tv` 형식)은 데이터 마이그레이션 시 `name` 컬럼 값으로 그대로 이전한다. 변환 없이 복사한다. - 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 초안 작성 이전에 확인할 질문으로 남긴다. 답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: trex-basic 계약 호환성 전략 — `GET /v1/assets` asset_id가 UUID로 바뀔 때 trex-basic 측의 대응 준비 일정이 확정되기 전에 portfolio-hub를 먼저 배포할 수 있는지, 아니면 임시 호환 레이어(name으로 fallback 조회)가 필요한지. - OQ-1: trex-basic 계약 호환성 전략 — `GET /v1/assets` asset_id가 UUID로 바뀔 때 trex-basic 측의 대응 준비 일정이 확정되기 전에 portfolio-hub를 먼저 배포할 수 있는지, 아니면 임시 호환 레이어(name으로 fallback 조회)가 필요한지.
- 상태: Open - 상태: Answered
- 처리 방안: trex-basic 팀과 협의 후 결정. 배포 순서 또는 호환 레이어 필요 여부를 REQ 단계 진입 전에 확정한다. - 처리 방안: trex-basic과 portfolio-hub를 동시에 변경·배포한다. 임시 호환 레이어 불필요.
- 종료 조건: trex-basic 팀 확인 완료 또는 임시 호환 레이어 구현 방안 확정 시 Answered 처리. - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
- OQ-2: `name` mutable 정책 — 생성 이후 name 변경을 허용할지 여부. 허용할 경우 기존 CLI 스크립트 참조 안정성 확보 방안. - OQ-2: `name` mutable 정책 — 생성 이후 name 변경을 허용할지 여부. 허용할 경우 기존 CLI 스크립트 참조 안정성 확보 방안.
- 상태: Open - 상태: Answered
- 처리 방안: 사용자(Justin) 결정. 단순성을 위해 immutable을 기본 제안으로 두고 확인한다. - 처리 방안: name 변경 허용. 단, 동일 사용자 내 같은 name이 존재하면 DB의 `(owner_user_id, name)` UNIQUE 제약으로 변경 거부. FR-3a에 반영 완료.
- 종료 조건: 사용자 확인 후 Answered 처리. 결과를 NFR 또는 FR에 반영. - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
- OQ-3: HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 여부 — 외부 API 소비자가 UUID를 직접 관리해야 하는 부담을 줄이기 위해 `/portfolios?user=alice&name=tv` 형태의 조회 경로가 필요한지. - OQ-3: HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 여부 — 외부 API 소비자가 UUID를 직접 관리해야 하는 부담을 줄이기 위해 `/portfolios?user=alice&name=tv` 형태의 조회 경로가 필요한지.
- 상태: Open - 상태: Answered
- 처리 방안: 사용자(Justin) 결정. 현재 trex-basic은 GET /v1/assets로 UUID를 조회하므로 별도 엔드포인트 불필요할 수 있다. - 처리 방안: 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회. A-4, Out of Scope 반영 완료.
- 종료 조건: 사용자 확인 후 Answered 또는 Deferred 처리. - 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
# 7. 초기 성공 기준 # 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. 이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
@@ -179,15 +184,15 @@
# 8. REQ로 넘기기 전 확인 체크 # 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [ ] 범위(In Scope/Out of Scope) 확정 - [x] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-3(HTTP API name 조회 엔드포인트) 결정 완료, Out of Scope 경계 승인 - 기준: OQ-3(HTTP API name 조회 엔드포인트) 결정 완료, Out of Scope 경계 승인
- [ ] 성공 지표의 측정 방식/데이터 출처 확정 - [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-4 측정 방식이 pytest 또는 코드 리뷰로 추적 가능한 형태로 기술됨 - 기준: S-1~S-4 측정 방식이 pytest 또는 코드 리뷰로 추적 가능한 형태로 기술됨
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(마이그레이션 데이터 무결성), R-2(trex-basic 계약 파괴) 완화 방안 확정 및 담당자 지정 - 기준: R-1(마이그레이션 데이터 무결성), R-2(trex-basic 계약 파괴) 완화 방안 확정 및 담당자 지정
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 - [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정 - [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Justin 또는 지정된 승인자가 confirm-discovery skill로 Freeze 확정 - 기준: Justin 또는 지정된 승인자가 confirm-discovery skill로 Freeze 확정
# 9. 파일 처리 결과 # 9. 파일 처리 결과
@@ -207,15 +212,15 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. 사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE ## DECIDE
- {{DECIDE_NAME_MUTABILITY}}: `name` 컬럼의 변경 허용 여부 — immutable(생성 후 변경 불가) 또는 mutable(변경 허용, 단 확인 절차 필요) 중 선택 (OQ-2) - ~~{{DECIDE_NAME_MUTABILITY}}~~: **Answered** — name mutable 허용. 동일 사용자 내 중복 name 변경은 DB UNIQUE 제약으로 거부. FR-3a 반영 완료. (OQ-2 2026-05-11)
- {{DECIDE_TREX_COMPAT_STRATEGY}}: trex-basic 계약 호환성 전략 — portfolio-hub 단독 배포 후 trex-basic 후속 배포 허용 여부, 또는 임시 호환 레이어(name fallback 조회) 구현 필요 여부 (OQ-1) - ~~{{DECIDE_TREX_COMPAT_STRATEGY}}~~: **Answered** — trex-basic과 portfolio-hub 동시 배포로 확정. 임시 호환 레이어 불필요. (OQ-1 2026-05-11)
## CONFIRM ## CONFIRM
- {{CONFIRM_API_NAME_LOOKUP_SCOPE}}: HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가가 이번 반복 Out of Scope임을 확인 (OQ-3) - ~~{{CONFIRM_API_NAME_LOOKUP_SCOPE}}~~: **Answered** — `(username, name)` 조합 엔드포인트 추가 안 함. HTTP API는 `portfolio_id`(UUID)만으로 조회. (OQ-3 2026-05-11)
- {{CONFIRM_MIGRATION_STRATEGY}}: 기존 portfolio_id 문자열을 name으로 변환 없이 그대로 복사하는 마이그레이션 전략 확인 (A-3) - ~~{{CONFIRM_MIGRATION_STRATEGY}}~~: **Answered** — 기존 `portfolio_id` 문자열을 `name` 컬럼으로 변환 없이 그대로 복사. (A-3 2026-05-11)
## DATA ## 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 # 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
@@ -228,8 +233,8 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) - 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그 ## Freeze 플래그
- Handoff Decision: 보류 - Handoff Decision: 승인
- Handoff Rationale: 초기 Discovery 초안 생성 완료. OQ-1(trex-basic 호환성), OQ-2(name mutable 여부), OQ-3(HTTP API name 조회 엔드포인트) 3개가 Open 상태이며 REQ 초안 작성 전 사용자 확인 필요. - Handoff Rationale: 모든 OQ Answered 완료, 모든 CONFIRM/DATA 항목 확정. confirm-discovery 게이트 통과 — REQ 초안 작성으로 인계 가능.
- Ready for REQ Drafting: false - Ready for REQ Drafting: true
- Confirmed By: TBD - Confirmed By: Justin
- Confirmed At (KST): TBD - Confirmed At (KST): 2026-05-11
+1 -1
View File
@@ -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-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-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-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 | | 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 |
+136 -3
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-04-30 22:13 - Last Updated (KST): 2026-05-11 00:00
- Source Discovery / Batch: bootstrap-baseline - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## 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 - 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 - 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": "<semver>", "checked_at": "<ISO8601 KST>"}` for readiness checks
### Error Contract ### Error Contract
@@ -72,6 +72,7 @@
- pfhub subcommands :: include user, portfolio, strategy, service-token, health-check, init-db, and migrate-db command families - 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 - 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 - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly
### Outputs ### Outputs
@@ -91,6 +92,136 @@
- Write commands require explicit confirmation unless --force is present. - Write commands require explicit confirmation unless --force is present.
- JSON output should remain machine-readable across additive command evolution. - 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 <service-token>` 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=<value>` (optional, filters by portfolio status)
- Response: HTTP 200
```
{
"total": <int>,
"assets": [
{
"asset_id": "<UUID>", // portfolio.portfolio_id (UUID after BAT-006)
"symbol": "<str>",
"market": "<str|null>",
"status": "<str>",
"cash": <float>,
"quantity": <float>,
"broker_type": "<str>",
"strategy_id": "<str|null>",
"strategy_type": "<str|null>",
"strategy_params": {<dict>},
"updated_at": "<ISO8601|null>",
"name": "<str|null>",
"price": <float|null>,
"profit_buffer": <float|null>,
"tags": [<str>]|null,
"owner_user_id": "<str|null>",
"created_at": "<ISO8601|null>",
"last_trade_at": "<ISO8601|null>"
}
]
}
```
- 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": "<str>",
"trading_enabled": <bool>, // mapped from users.is_active
"disabled_reason": "<str|null>", // "account_inactive" when trading_enabled=false
"brokers": [
{
"broker": "<str>",
"account_ref": "<str>",
"credential_payload": {<dict>}|null, // Fernet-decrypted {app_key, app_secret}
"credential_version": <int>,
"last_rotated_at": "<ISO8601|null>"
}
],
"user_id": "<str>",
"username": "<str>",
"email": "<str|null>",
"permission": "<str>"
}
```
- 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": "<str>", // required, min_length=1
"execution_id": "<str>", // required, min_length=1
"side": "buy"|"sell",
"quantity": <decimal>, // > 0
"price": <decimal>, // > 0
"fee_amount": <decimal>, // >= 0, default 0
"tax_amount": <decimal>, // >= 0, default 0
"executed_at": "<ISO8601>"
}
```
- Response HTTP 201 (new record):
```
{
"ledger_entry_id": <int>,
"asset_id": "<str>",
"order_id": "<str>",
"execution_id": "<str>",
"side": "<str>",
"quantity": <float>,
"price": <float>,
"fee_amount": <float>,
"tax_amount": <float>,
"executed_at": "<ISO8601>",
"recorded_at": "<ISO8601|null>",
"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 ## Shared Constraints
- Token permission levels gate both HTTP and CLI mutations consistently. - Token permission levels gate both HTTP and CLI mutations consistently.
@@ -111,4 +242,6 @@
## Change Log ## 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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
+29 -2
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-01 00:00 - Last Updated (KST): 2026-05-11 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007
## Project Summary ## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. - 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. - Holds framework-light domain models shared by services, routes, and repositories.
- app/repositories/ - app/repositories/
- Provides the repository protocol, repository factory, and concrete Postgres persistence implementation. - 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/ - app/schemas/
- Defines request and response schemas for HTTP contracts. - Defines request and response schemas for HTTP contracts.
- `v1.py`: Contract schemas for v1 API (AssetItem, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response). - `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. - 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. - .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 ## Current Gaps / Planned Changes
- The non-DB repository path intentionally raises a feature-not-ready error when persistence is disabled. - The non-DB repository path intentionally raises a feature-not-ready error when persistence is disabled.
@@ -136,4 +161,6 @@
## Change Log ## 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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
+61 -2
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-01 00:00 - Last Updated (KST): 2026-05-11 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -50,6 +50,17 @@
4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied. 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. 5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
## Flow: `CLI Portfolio Command — Name+User Resolution`
1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `<name>` positional argument and `--user <username>` option.
2. `_resolve_portfolio_id(repository, name, user)` is called before any mutation:
a. Calls `get_user_by_username(user)` → raises ValueError if user not found.
b. Calls `get_portfolio_by_name_and_user(owner_user_id, name)` → raises ValueError if portfolio not found.
c. Returns `portfolio.portfolio_id` (UUID) for downstream use.
3. `create` command takes `<name>` as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.
4. `update --new-name <new_name>` renames the portfolio; `--force` is required to confirm the rename.
5. `comment` and `tag` sub-commands continue to accept `<portfolio_id>` (UUID) directly; they are outside the name+user resolution flow.
## Flow Constraints ## Flow Constraints
- Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings. - Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings.
@@ -71,6 +82,7 @@
3. Optional `?status=<value>` query param filters portfolios by status field. 3. Optional `?status=<value>` 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 `{}`. 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`. 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` ## Flow: `v1 API — GET /v1/user_info`
@@ -102,6 +114,50 @@
2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`. 2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`.
3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup. 3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup.
## Flow: `Local DB Initialization and Validation`
### Scenario A — Fresh DB (DB 미존재 상태)
1. `docker compose -f docker-compose.local-test.yml up -d` — TimescaleDB 컨테이너 시작.
2. 컨테이너 준비 확인: `docker compose -f docker-compose.local-test.yml ps` → `portfolio-hub-timescaledb` 상태가 `healthy` 인지 확인.
3. `pfhub_DB_ENABLED=true pfhub init-db` — 대상 DB 생성 + Alembic `upgrade head` 실행.
- 기대 출력: `database: portfolio_hub`, `created: True`, `schema_initialized: True`, `migration_applied: head`
- pass 기준: exit code 0, 위 필드 모두 출력
- fail 기준: connection refused, permission denied, Alembic revision 오류
4. `pfhub_DB_ENABLED=true pfhub health-check` — DB ping 성공 확인.
- 기대 출력: `db_enabled: True`, `repository: PostgresPortfolioRepository`
- pass 기준: exit code 0, `db_enabled: True`
5. Smoke flow: 사용자 생성 → portfolio 생성 → `portfolio list --user <username>` 확인.
- pass 기준: 생성된 portfolio가 list 출력에 포함됨
### Scenario B — runtime-precreated schema (ensure_schema() 선행)
1. DB 컨테이너 시작 (Scenario A 1~2 동일).
2. `pfhub_DB_ENABLED=true uvicorn app.main:app` — App 기동. `ensure_schema()`가 핵심 테이블 생성.
3. `pfhub_DB_ENABLED=true pfhub migrate-db` — `bootstrap_or_upgrade_to_head()` 실행.
- 내부 동작: `alembic_version` 테이블 없음 → 기존 핵심 테이블(portfolios 등) 존재 → legacy stamp → head로 stamp + 미보유 객체(broker_credentials, ledger_trades, runtime_id 등) upgrade
- 기대 출력: `migration_applied: head`
- pass 기준: exit code 0, 이후 `pfhub health-check` exit code 0
4. `pfhub_DB_ENABLED=true pfhub health-check` 재확인.
### Repeatable Run Criteria
- 모든 시나리오는 컨테이너 볼륨 초기화(`docker compose down -v`) 후 재실행 가능.
- 출력 형식: `pfhub --output json <command>` 로 JSON 출력 수집 시 필드명이 고정됨.
- 로그 수집 위치: `docker compose logs portfolio-hub-timescaledb` — DB 측 오류 시 참조.
- Evidence 형식: exit code + 주요 필드 값 스크린샷 또는 텍스트 캡처 → batch verification.md에 기록.
## Flow: `Alembic vs ensure_schema() Schema Ownership Boundary`
`ensure_schema()` (postgres.py)와 Alembic revision 양쪽이 다루는 객체가 존재한다. 충돌은 `bootstrap_or_upgrade_to_head()`의 legacy stamp 경로 + 각 revision의 IF NOT EXISTS guard로 처리된다.
### 경계 규칙
1. **ensure_schema() 선행 가능 객체**: users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 관련 인덱스. 모두 IF NOT EXISTS로 보호됨.
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 ## Current Gaps / Planned Changes
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending. - End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
@@ -110,5 +166,8 @@
## Change Log ## 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-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. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
@@ -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 명령 흐름: `<name>` + `--user <username>` 조합으로 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 명령 흐름 설명에 `<name>` + `--user <username>` 조합 식별 방식이 반영되고, `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 확인 완료
@@ -1,6 +1,6 @@
# req-004: GET /health 응답 스키마 확장 # req-004: GET /health 응답 스키마 확장
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -32,6 +32,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-005: service-token 발급 시 runtime_id 동시 생성 # req-005: service-token 발급 시 runtime_id 동시 생성
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -34,6 +34,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-006: GET /v1/assets 엔드포인트 구현 # req-006: GET /v1/assets 엔드포인트 구현
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -39,6 +39,35 @@
## Change Log ## 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 ### CHG-20260503-01
- Date: 2026-05-03 - Date: 2026-05-03
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-007: GET /v1/user_info 엔드포인트 구현 # req-007: GET /v1/user_info 엔드포인트 구현
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -38,6 +38,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
@@ -1,6 +1,6 @@
# req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 # req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -36,6 +36,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
@@ -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 뮤테이션 명령을 `<name>` + `--user <username>` 조합으로 전환해 소유권 검증을 명령 실행 경로에 포함한다.
- CLI 내부에서 `(owner_user_id, name)` → UUID 단일 쿼리 조회 후 처리하고, 조회 실패 시 명확한 오류 메시지를 출력한다.
## Requirement
- `portfolio create` 명령은 `name` Argument와 `--user <username>` 옵션을 필수로 받는다. `portfolio_id`는 서버가 UUID로 발급하며 사용자가 직접 지정하거나 조회할 수 없다.
- `status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update` 명령은 `<name>` Argument와 `--user <username>` 옵션으로 portfolio를 식별한다. 내부에서 `(owner_user_id, name)` → `portfolio_id(UUID)`를 단일 DB 쿼리로 조회한 뒤 처리한다.
- `<name>` + `--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 <name> --user <username> ...`을 실행하면, When 서버가 UUID를 발급하면, Then CLI가 portfolio_id를 직접 지정하거나 조회하지 않고 UUID가 DB에 저장된다.
2. Given `pfhub portfolio buy <name> --user alice ...`를 실행하면, When `(alice, <name>)` 조합으로 DB를 단일 쿼리로 조회해 UUID를 찾으면, Then 해당 portfolio에 buy 작업이 수행된다.
3. Given `--user bob`으로 alice 소유의 portfolio `<name>`을 조작하면, When `(bob, <name>)` 조합으로 조회해 레코드가 없으면, 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 전체 통과)
@@ -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 철회 주석 직접 확인 완료
@@ -1,6 +1,6 @@
# req-009: v1 API 지원을 위한 DB 스키마 migration 추가 # req-009: v1 API 지원을 위한 DB 스키마 migration 추가
- Status: Approved - Status: Implemented
- Type: Migration - Type: Migration
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -39,6 +39,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
@@ -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)
@@ -1,6 +1,6 @@
# req-010: credential_payload Fernet 암호화 및 startup validation # req-010: credential_payload Fernet 암호화 및 startup validation
- Status: Approved - Status: Implemented
- Type: Non-Functional - Type: Non-Functional
- Priority: High - Priority: High
- Owner: repository-maintainers - Owner: repository-maintainers
@@ -35,6 +35,35 @@
## Change Log ## 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 ### CHG-20260502-02
- Date: 2026-05-02 - Date: 2026-05-02
- Author: repository-maintainers - Author: repository-maintainers
+16 -15
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-016` - Current: `REQ-020`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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-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 | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.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 | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.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 | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-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 | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-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 | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.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 | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.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-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-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-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-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-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 ## Recent Change Log Summary
- 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 재검증 필요 - 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 명령 구현
@@ -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")
+23 -10
View File
@@ -208,12 +208,11 @@ class FakeRepo:
def create_portfolio( def create_portfolio(
self, self,
portfolio_id: str, name: str,
*, *,
owner_user_id: str, owner_user_id: str,
broker: str, broker: str,
status: str, status: str,
name: str,
symbol: str, symbol: str,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
@@ -222,6 +221,7 @@ class FakeRepo:
active_strategy_id: str | None, active_strategy_id: str | None,
avabile_strategies: list[str] | None, avabile_strategies: list[str] | None,
) -> Portfolio: ) -> Portfolio:
portfolio_id = str(uuid.uuid4())
self._portfolio = Portfolio( self._portfolio = Portfolio(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,
broker=broker, broker=broker,
@@ -238,6 +238,13 @@ class FakeRepo:
) )
return self._portfolio 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: def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
portfolio = self.get(portfolio_id) portfolio = self.get(portfolio_id)
for key, value in fields.items(): for key, value in fields.items():
@@ -484,7 +491,7 @@ def _portfolio() -> Portfolio:
portfolio_id="005930:tv", portfolio_id="005930:tv",
broker="kis", broker="kis",
status="enabled", status="enabled",
name="Samsung Electronics", name="005930:tv",
symbol="005930", symbol="005930",
cash_balance=1_000_000.0, cash_balance=1_000_000.0,
asset_quantity=1.0, asset_quantity=1.0,
@@ -511,10 +518,14 @@ def test_cli_list_renders_table() -> None:
def test_cli_status_supports_json_output() -> 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) 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 assert result.exit_code == 0
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
@@ -549,6 +560,8 @@ def test_cli_create_buy_and_delete_flow() -> None:
"portfolio", "portfolio",
"buy", "buy",
"005930:tv", "005930:tv",
"--user",
"alice",
"--quantity", "--quantity",
"2", "2",
"--price", "--price",
@@ -560,12 +573,12 @@ def test_cli_create_buy_and_delete_flow() -> None:
) )
assert repo.entries[0].action == "BUY" assert repo.entries[0].action == "BUY"
assert repo._portfolio.owner_user_id == user.id 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 create_result.exit_code == 0
assert buy_result.exit_code == 0 assert buy_result.exit_code == 0
assert delete_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: 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 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: 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"], ["portfolio", "create", "005930:tv", "--symbol", "005930", "--force"],
) )
assert result.exit_code == 1 assert result.exit_code != 0
assert "--user" in result.stdout assert "--user" in result.output
def test_cli_portfolio_create_with_valid_user_sets_owner() -> None: def test_cli_portfolio_create_with_valid_user_sets_owner() -> None:
+1 -1
View File
@@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N
response = client.post( response = client.post(
"/portfolios", "/portfolios",
headers={"Authorization": "Bearer trade-env-token"}, headers={"Authorization": "Bearer trade-env-token"},
json={"portfolio_id": "new-1", "symbol": "005930"}, json={"name": "new-1", "symbol": "005930"},
) )
app.dependency_overrides.clear() app.dependency_overrides.clear()
+563
View File
@@ -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"