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:
@@ -175,6 +175,94 @@ pfhub_DB_ENABLED=true pfhub portfolio list
|
||||
|
||||
This repository now uses Alembic for versioned schema migrations. Future schema changes should be added under `migrations/versions`.
|
||||
|
||||
## Local DB Validation Runbook
|
||||
|
||||
이 섹션은 로컬 docker compose 환경에서 DB 초기화, 마이그레이션, health-check, smoke flow를 반복 검증하기 위한 절차를 정의한다.
|
||||
|
||||
### 준비 — 컨테이너 시작
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local-test.yml up -d
|
||||
docker compose -f docker-compose.local-test.yml ps
|
||||
# portfolio-hub-timescaledb 상태가 (healthy) 인지 확인
|
||||
```
|
||||
|
||||
기본 접속 정보 (`.env` 설정 기준):
|
||||
- TimescaleDB: `127.0.0.1:5432`
|
||||
- DB: `portfolio_hub`, User: `portfolio_hub_user`
|
||||
|
||||
### Scenario A — Fresh DB (DB 미존재 상태)
|
||||
|
||||
```bash
|
||||
# 1. DB 생성 + Alembic upgrade head
|
||||
pfhub_DB_ENABLED=true pfhub init-db
|
||||
# 기대: database: portfolio_hub / created: True / schema_initialized: True / migration_applied: head
|
||||
|
||||
# 2. DB 연결 health 확인
|
||||
pfhub_DB_ENABLED=true pfhub health-check
|
||||
# 기대: db_enabled: True / repository: PostgresPortfolioRepository
|
||||
|
||||
# 3. Smoke flow — 사용자 생성 + portfolio 생성 + 목록 확인
|
||||
pfhub_DB_ENABLED=true pfhub user create --username alice --password password123 --force
|
||||
pfhub_DB_ENABLED=true pfhub portfolio create my-portfolio --symbol 005930 --user alice --cash-balance 1000000 --force
|
||||
pfhub_DB_ENABLED=true pfhub portfolio list --user alice
|
||||
# 기대: my-portfolio가 목록에 포함
|
||||
```
|
||||
|
||||
**pass 기준**: 각 명령 exit code 0 / 기대 필드 출력 확인
|
||||
**fail 기준**: connection refused / Alembic revision 오류 / exit code ≠ 0
|
||||
|
||||
### Scenario B — runtime-precreated schema (App 선행 기동)
|
||||
|
||||
```bash
|
||||
# 1. App 먼저 기동 (ensure_schema() 실행)
|
||||
pfhub_DB_ENABLED=true uvicorn app.main:app &
|
||||
|
||||
# 2. migrate-db로 Alembic 관리 상태로 전환
|
||||
pfhub_DB_ENABLED=true pfhub migrate-db
|
||||
# 기대: migration_applied: head
|
||||
|
||||
# 3. health-check 재확인
|
||||
pfhub_DB_ENABLED=true pfhub health-check
|
||||
```
|
||||
|
||||
**동작 원리**: `bootstrap_or_upgrade_to_head()`가 `alembic_version` 테이블 없음 → 기존 핵심 테이블 존재 → legacy stamp → head upgrade 순서로 실행한다.
|
||||
|
||||
### 반복 실행 (재현성 확인)
|
||||
|
||||
```bash
|
||||
# 컨테이너 + 볼륨 초기화
|
||||
docker compose -f docker-compose.local-test.yml down -v
|
||||
|
||||
# 위 Scenario A 또는 B 재실행
|
||||
```
|
||||
|
||||
출력이 이전 실행과 동일한지 비교한다. JSON 출력 수집:
|
||||
|
||||
```bash
|
||||
pfhub_DB_ENABLED=true pfhub init-db --output json
|
||||
pfhub_DB_ENABLED=true pfhub health-check --output json
|
||||
```
|
||||
|
||||
로그 수집 위치: `docker compose -f docker-compose.local-test.yml logs portfolio-hub-timescaledb`
|
||||
|
||||
### Rollback / 재시도 판단 기준
|
||||
|
||||
| 증상 | 판단 | 조치 |
|
||||
|------|------|------|
|
||||
| `init-db`: `connection refused` | DB 컨테이너 미준비 | `docker compose ps` → `(healthy)` 대기 후 재시도 |
|
||||
| `migrate-db`: `Alembic revision 오류` | revision 파일 손상 또는 순서 오류 | `migrations/versions/` revision 확인 |
|
||||
| `migrate-db` 후 v1 API 오류 | ensure_schema() 선행 후 Alembic 미실행 | `migrate-db` 재실행 |
|
||||
| 데이터 오염으로 스키마 불일치 | 심각한 데이터 오류 | 볼륨 초기화(`down -v`) + Scenario A 재시작 |
|
||||
|
||||
### Release Gate Criteria
|
||||
|
||||
다음 조건을 모두 만족해야 로컬 검증 완료로 간주한다:
|
||||
|
||||
1. Scenario A: `init-db` exit code 0 + `health-check` exit code 0 + smoke flow 정상 완료
|
||||
2. `migrate-db` 반복 실행 시 오류 없음 (멱등성 확인)
|
||||
3. `docker compose logs`에 ERROR 레벨 로그 없음
|
||||
|
||||
## Docs Sync Guardrail
|
||||
|
||||
To keep implementation and specification documents in sync, this repository provides an automated check.
|
||||
|
||||
+123
-80
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
@@ -22,6 +23,7 @@ from app.schemas.portfolio import (
|
||||
)
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")
|
||||
|
||||
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
|
||||
comment_app = typer.Typer(help="Portfolio comment history commands.")
|
||||
@@ -71,6 +73,25 @@ def _confirm(force: bool, message: str) -> None:
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
|
||||
def _validate_portfolio_name(name: str, ctx_console=None) -> None:
|
||||
if not _NAME_RE.match(name):
|
||||
msg = f"유효하지 않은 이름입니다: {name!r} — [a-zA-Z0-9._:-] 문자만 허용하며 1~100자여야 합니다."
|
||||
if ctx_console is not None:
|
||||
ctx_console.print(f"Error: {msg}")
|
||||
raise typer.Exit(code=1)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _resolve_portfolio_id(repository, name: str, user: str) -> str:
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
|
||||
portfolio = repository.get_portfolio_by_name_and_user(owner.id, name)
|
||||
if portfolio is None:
|
||||
raise ValueError(f"포트폴리오를 찾을 수 없습니다: {name} (user: {user})")
|
||||
return portfolio.portfolio_id
|
||||
|
||||
|
||||
@portfolio_app.command("list")
|
||||
def list_portfolios(
|
||||
ctx: typer.Context,
|
||||
@@ -105,22 +126,25 @@ def list_portfolios(
|
||||
@portfolio_app.command("status")
|
||||
def portfolio_status(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
recent: int = typer.Option(5, "--recent", min=1, help="Number of recent ledger entries."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent),
|
||||
)
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent)
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("create")
|
||||
def create_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
user: str | None = typer.Option(None, "--user", help="Owner username (required)."),
|
||||
name: str | None = typer.Option(None, help="Asset display name."),
|
||||
name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
symbol: str = typer.Option(..., help="Trading symbol."),
|
||||
broker: str = typer.Option("kis", help="Broker code."),
|
||||
status: str = typer.Option("enabled", help="Portfolio status."),
|
||||
@@ -132,10 +156,8 @@ def create_portfolio(
|
||||
strategy_params: str = typer.Option("{}", help="Strategy params as JSON object."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
if user is None:
|
||||
ctx.obj.console.print("Error: --user 옵션은 필수입니다.")
|
||||
raise typer.Exit(code=1)
|
||||
_confirm(force, f"Create portfolio {portfolio_id} for symbol {symbol}?")
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Create portfolio {name!r} for symbol {symbol} (user: {user})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
owner = repository.get_user_by_username(user)
|
||||
@@ -143,7 +165,6 @@ def create_portfolio(
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
|
||||
return service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
portfolio_id=portfolio_id,
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
broker=broker,
|
||||
@@ -165,40 +186,50 @@ def create_portfolio(
|
||||
@portfolio_app.command("update")
|
||||
def update_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
broker: str | None = typer.Option(None, help="Broker code."),
|
||||
status: str | None = typer.Option(None, help="Portfolio status."),
|
||||
name: str | None = typer.Option(None, help="Asset display name."),
|
||||
new_name: str | None = typer.Option(None, "--new-name", help="New portfolio name (requires --force or confirm)."),
|
||||
symbol: str | None = typer.Option(None, help="Trading symbol."),
|
||||
strategy_name: str | None = typer.Option(None, help="Strategy name."),
|
||||
strategy_params: str | None = typer.Option(None, help="Strategy params as JSON object."),
|
||||
asset_price: float | None = typer.Option(None, min=0.0, help="Current asset price."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Update portfolio {portfolio_id}?")
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
if new_name is not None:
|
||||
_validate_portfolio_name(new_name, ctx.obj.console)
|
||||
_confirm(force, f"Rename portfolio {name!r} → {new_name!r} (user: {user})?")
|
||||
else:
|
||||
_confirm(force, f"Update portfolio {name!r} (user: {user})?")
|
||||
|
||||
parsed_strategy_params = None if strategy_params is None else _parse_strategy_params(strategy_params)
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.update_portfolio(
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.update_portfolio(
|
||||
portfolio_id,
|
||||
UpdatePortfolioRequest(
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=name,
|
||||
name=new_name,
|
||||
symbol=symbol,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=parsed_strategy_params,
|
||||
asset_price=asset_price,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("buy")
|
||||
def buy_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
quantity: float = typer.Option(..., min=0.0000001, help="Quantity to buy."),
|
||||
price: float = typer.Option(..., min=0.0000001, help="Execution price."),
|
||||
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."),
|
||||
@@ -206,22 +237,23 @@ def buy_portfolio(
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
total_cost = quantity * price + fee + tax
|
||||
_confirm(force, f"Buy {quantity:g} units of {portfolio_id} at {price:,.0f} (total {total_cost:,.0f})?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.buy(
|
||||
portfolio_id,
|
||||
BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note),
|
||||
),
|
||||
)
|
||||
_confirm(force, f"Buy {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (total {total_cost:,.0f})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.buy(portfolio_id, BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note))
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("sell")
|
||||
def sell_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
quantity: float = typer.Option(..., min=0.0000001, help="Quantity to sell."),
|
||||
price: float = typer.Option(..., min=0.0000001, help="Execution price."),
|
||||
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."),
|
||||
@@ -229,96 +261,107 @@ def sell_portfolio(
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
net_amount = quantity * price - fee - tax
|
||||
_confirm(force, f"Sell {quantity:g} units of {portfolio_id} at {price:,.0f} (net {net_amount:,.0f})?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.sell(
|
||||
portfolio_id,
|
||||
SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note),
|
||||
),
|
||||
)
|
||||
_confirm(force, f"Sell {quantity:g} units of {name!r} (user: {user}) at {price:,.0f} (net {net_amount:,.0f})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.sell(portfolio_id, SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note))
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("invest")
|
||||
def invest_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
movement_type: str = typer.Option(..., "--type", help="deposit, profit, or withdraw."),
|
||||
amount: float = typer.Option(..., min=0.0000001, help="Buffer adjustment amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.invest(
|
||||
portfolio_id,
|
||||
InvestRequest(movement_type=movement_type, amount=amount, note=note),
|
||||
),
|
||||
)
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {name!r} (user: {user})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.invest(portfolio_id, InvestRequest(movement_type=movement_type, amount=amount, note=note))
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("transfer")
|
||||
def transfer_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
direction: str = typer.Option(..., help="to-buffer or from-buffer."),
|
||||
amount: float = typer.Option(..., min=0.0000001, help="Transfer amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.transfer(
|
||||
portfolio_id,
|
||||
TransferRequest(direction=direction, amount=amount, note=note),
|
||||
),
|
||||
)
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {name!r} (user: {user})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.transfer(portfolio_id, TransferRequest(direction=direction, amount=amount, note=note))
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("reset")
|
||||
def reset_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
infer_symbol: str | None = typer.Option(None, help="Fallback symbol when portfolio does not exist."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Reset portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol)),
|
||||
)
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Reset portfolio {name!r} (user: {user})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol))
|
||||
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("delete")
|
||||
def delete_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
name: str = typer.Argument(..., help="Portfolio name."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
keep_ledger: bool = typer.Option(False, help="Preserve ledger history."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
suffix = " and keep ledger" if keep_ledger else ""
|
||||
_confirm(force, f"Delete portfolio {portfolio_id}{suffix}?")
|
||||
_with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.delete(portfolio_id, keep_ledger=keep_ledger),
|
||||
)
|
||||
_confirm(force, f"Delete portfolio {name!r} (user: {user}){suffix}?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
service.delete(portfolio_id, keep_ledger=keep_ledger)
|
||||
return portfolio_id
|
||||
|
||||
pid = _with_service_and_repo(ctx, _callback)
|
||||
if ctx.obj.output_format == "json":
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": portfolio_id, "keep_ledger": keep_ledger})
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": name, "keep_ledger": keep_ledger})
|
||||
else:
|
||||
ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}")
|
||||
ctx.obj.console.print(f"Deleted portfolio: {name}")
|
||||
|
||||
|
||||
@comment_app.command("add")
|
||||
def comment_add(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
content: str = typer.Option(..., "--content", help="Comment text (supports multiline)."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
@@ -334,7 +377,7 @@ def comment_add(
|
||||
@comment_app.command("list")
|
||||
def comment_list(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
@@ -346,7 +389,7 @@ def comment_list(
|
||||
@comment_app.command("update")
|
||||
def comment_update(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
comment_id: str = typer.Argument(..., help="Comment identifier."),
|
||||
content: str = typer.Option(..., "--content", help="Updated comment text."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
@@ -366,7 +409,7 @@ def comment_update(
|
||||
@comment_app.command("delete")
|
||||
def comment_delete(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
comment_id: str = typer.Argument(..., help="Comment identifier."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
@@ -384,7 +427,7 @@ def comment_delete(
|
||||
@tag_app.command("add")
|
||||
def tag_add(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag: str = typer.Option(..., "--tag", help="Tag value."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
@@ -397,7 +440,7 @@ def tag_add(
|
||||
@tag_app.command("list")
|
||||
def tag_list(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
) -> None:
|
||||
detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id))
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No tags found.")
|
||||
@@ -406,7 +449,7 @@ def tag_list(
|
||||
@tag_app.command("remove")
|
||||
def tag_remove(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag: str = typer.Option(..., "--tag", help="Tag value."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
@@ -418,4 +461,4 @@ def tag_remove(
|
||||
if ctx.obj.output_format == "json":
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, {"portfolio_id": portfolio_id, "deleted": tag.strip()})
|
||||
else:
|
||||
ctx.obj.console.print(f"Removed tag: {tag.strip()}")
|
||||
ctx.obj.console.print(f"Removed tag: {tag.strip()}")
|
||||
|
||||
@@ -39,12 +39,11 @@ class PortfolioDomainRepository(Protocol):
|
||||
) -> Portfolio: ...
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
name: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
@@ -53,6 +52,7 @@ class PortfolioDomainRepository(Protocol):
|
||||
active_strategy_id: str | None,
|
||||
avabile_strategies: list[str] | None,
|
||||
) -> Portfolio: ...
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ...
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
|
||||
def list_portfolios(
|
||||
self,
|
||||
|
||||
@@ -198,6 +198,21 @@ class PostgresPortfolioRepository:
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
|
||||
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'uq_portfolios_owner_name'
|
||||
AND table_name = 'portfolios'
|
||||
) THEN
|
||||
ALTER TABLE portfolios
|
||||
ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name);
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_ledger (
|
||||
@@ -717,12 +732,11 @@ class PostgresPortfolioRepository:
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
name: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
@@ -731,42 +745,58 @@ class PostgresPortfolioRepository:
|
||||
active_strategy_id: str | None,
|
||||
avabile_strategies: list[str] | None,
|
||||
) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
portfolio_id = str(uuid.uuid4())
|
||||
try:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
name,
|
||||
symbol,
|
||||
owner_user_id,
|
||||
float(cash_balance),
|
||||
float(asset_quantity),
|
||||
float(asset_price),
|
||||
float(profit_buffer),
|
||||
active_strategy_id,
|
||||
avabile_strategies,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
portfolio.tags = []
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
|
||||
raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc
|
||||
raise
|
||||
return portfolio
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
name,
|
||||
symbol,
|
||||
owner_user_id,
|
||||
float(cash_balance),
|
||||
float(asset_quantity),
|
||||
float(asset_price),
|
||||
float(profit_buffer),
|
||||
active_strategy_id,
|
||||
avabile_strategies,
|
||||
),
|
||||
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
|
||||
(owner_user_id, name),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
portfolio.tags = []
|
||||
return portfolio
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
@@ -794,20 +824,27 @@ class PostgresPortfolioRepository:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
assignments.append("updated_at = NOW()")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE portfolios
|
||||
SET {', '.join(assignments)}
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(*params, portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
try:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE portfolios
|
||||
SET {', '.join(assignments)}
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(*params, portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
|
||||
new_name = fields.get("name", portfolio_id)
|
||||
raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc
|
||||
raise
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def list_portfolios(
|
||||
|
||||
@@ -5,8 +5,12 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
import re as _re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
_PORTFOLIO_NAME_RE = _re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")
|
||||
|
||||
|
||||
class StrategySetResponse(BaseModel):
|
||||
id: str
|
||||
@@ -70,7 +74,7 @@ class PortfolioDetailResponse(BaseModel):
|
||||
|
||||
|
||||
class CreatePortfolioRequest(PortfolioBase):
|
||||
portfolio_id: str
|
||||
name: str # required, overrides Optional from PortfolioBase; used as (owner_user_id, name) identifier
|
||||
strategy_name: str = "target_value"
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
active_strategy_id: str | None = None
|
||||
@@ -80,6 +84,15 @@ class CreatePortfolioRequest(PortfolioBase):
|
||||
asset_price: float = Field(default=0.0, ge=0.0)
|
||||
profit_buffer: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not _PORTFOLIO_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"이름은 [a-zA-Z0-9._:-] 문자만 허용하며 1~100자여야 합니다: {v!r}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class UpdatePortfolioRequest(BaseModel):
|
||||
broker: str | None = None
|
||||
|
||||
@@ -214,11 +214,10 @@ class PortfolioService:
|
||||
active_strategy_id = strategy_set.id
|
||||
avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies)
|
||||
portfolio = self._repository.create_portfolio(
|
||||
payload.portfolio_id,
|
||||
payload.name,
|
||||
owner_user_id=owner_user_id,
|
||||
broker=payload.broker,
|
||||
status=payload.status,
|
||||
name=payload.name or payload.symbol,
|
||||
symbol=payload.symbol,
|
||||
cash_balance=payload.cash_balance,
|
||||
asset_quantity=payload.asset_quantity,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BAT-001 Index
|
||||
|
||||
- Status: in-delivery
|
||||
- Status: release-candidate
|
||||
- Batch: BAT-001
|
||||
- Profile: standard
|
||||
- Created: 2026-05-02
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
| Stage | File | Status |
|
||||
|-------|------|--------|
|
||||
| Planning | [planning.md](./planning.md) | draft |
|
||||
| Design | [design.md](./design.md) | draft |
|
||||
| Implementation | [implementation.md](./implementation.md) | draft |
|
||||
| Verification | [verification.md](./verification.md) | draft |
|
||||
| Planning | [planning.md](./planning.md) | confirmed |
|
||||
| Design | [design.md](./design.md) | confirmed |
|
||||
| Implementation | [implementation.md](./implementation.md) | completed |
|
||||
| Verification | [verification.md](./verification.md) | re-verified |
|
||||
|
||||
@@ -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 |
|
||||
+64
@@ -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)과 맞지 않으면 수동 검증 절차 수정 필요
|
||||
+59
@@ -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를 갱신함.
|
||||
@@ -12,4 +12,7 @@
|
||||
| BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) |
|
||||
| BAT-003 | batch-lite | release-candidate | REQ-013 | dcy-003 | [bat-003_20260502_portfolio-list-user-required](./bat-003_20260502_portfolio-list-user-required/) |
|
||||
| BAT-004 | batch-lite | release-candidate | REQ-014 | minor-change | [bat-004_20260502_portfolio-create-user-required](./bat-004_20260502_portfolio-create-user-required/) |
|
||||
| BAT-005 | batch-lite | release-candidate | REQ-015 | minor-change | [bat-005_20260503_broker-credential-cli](./bat-005_20260503_broker-credential-cli/) |
|
||||
| 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
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-04-30 22:13
|
||||
- Source Discovery / Batch: bootstrap-baseline
|
||||
- Last Updated (KST): 2026-05-11 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008
|
||||
|
||||
## Project Summary
|
||||
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
|
||||
@@ -29,6 +29,9 @@
|
||||
|
||||
- Portfolio :: owned investment portfolio with balances, strategy references, tags, comments, and ledger history
|
||||
- User :: authenticated principal that owns portfolios and receives client or service tokens
|
||||
- AuthToken :: issued client or service token with permission and optional runtime_id
|
||||
- BrokerCredential :: encrypted broker API credentials per user-broker pair
|
||||
- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id)
|
||||
|
||||
## Entity: `Portfolio`
|
||||
|
||||
@@ -38,22 +41,29 @@
|
||||
|
||||
### Key Fields
|
||||
|
||||
- portfolio_id :: text :: stable portfolio primary key
|
||||
- portfolio_id :: uuid :: server-generated UUID primary key; assigned at creation and never changed; BAT-006 migrated all pre-existing TEXT portfolio_id values to UUIDs
|
||||
- name :: text :: human-readable portfolio name; unique per owner_user_id; validated against [a-zA-Z0-9._:-]{1,100}
|
||||
- symbol :: text :: traded asset symbol for the portfolio
|
||||
- market :: text|null :: market identifier for the asset (e.g., exchange code); added by migration 20260502_0014
|
||||
- owner_user_id :: text|null :: owning user reference for attribution and authorization
|
||||
- active_strategy_id :: text|null :: currently selected strategy-set reference
|
||||
- cash_balance :: double precision :: current cash snapshot after the latest ledger event
|
||||
- asset_quantity :: double precision :: current holdings snapshot after the latest ledger event
|
||||
|
||||
### Uniqueness Constraints
|
||||
|
||||
- uq_portfolios_owner_name :: UNIQUE (owner_user_id, name) :: enforces per-user portfolio name uniqueness at the DB level
|
||||
|
||||
### Relationships
|
||||
|
||||
- User :: owner_user_id links a portfolio to its owning user when ownership is assigned
|
||||
- StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions
|
||||
- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id
|
||||
- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id (UUID)
|
||||
|
||||
### State Rules
|
||||
|
||||
- Portfolio names are required and are backfilled from symbol when older rows are migrated.
|
||||
- Portfolio names are required, must be unique per owner_user_id, and are validated at the schema layer before persistence.
|
||||
- portfolio_id is server-generated at creation; clients must never supply or assume a specific portfolio_id value.
|
||||
- Balance-changing commands must update snapshot fields and append a ledger row together.
|
||||
- Tags are unique per portfolio and comments are retrieved in created_at descending order.
|
||||
|
||||
@@ -84,11 +94,95 @@
|
||||
- Bootstrap admin creation only runs when the users table is empty and DB mode is enabled.
|
||||
- Permission changes affect both API guards and CLI mutation capabilities.
|
||||
|
||||
## Entity: `AuthToken`
|
||||
|
||||
- Purpose: represent an issued client or service token linked to a user with permission and optional runtime identifier
|
||||
- Source of Truth: auth_tokens table
|
||||
- Lifecycle: issued -> optionally rotated -> revoked or expired
|
||||
|
||||
### Key Fields
|
||||
|
||||
- id :: text :: primary key; raw token value (stored hashed in practice)
|
||||
- user_id :: text :: owning user reference
|
||||
- token_type :: text :: "client" or "service"
|
||||
- token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens
|
||||
- permission :: text :: read, trade-write, or admin
|
||||
- service_name :: text|null :: logical name for service tokens (unique within token_type="service")
|
||||
- runtime_id :: text|null :: opaque runtime identifier assigned at service-token issuance; unique (partial index on non-NULL values); added by migration 20260502_0014
|
||||
- expires_at :: timestamptz|null :: token expiry; null for non-expiring tokens
|
||||
- created_at :: timestamptz :: issuance timestamp
|
||||
|
||||
### Uniqueness Constraints
|
||||
|
||||
- idx_auth_tokens_service_name_unique :: UNIQUE (service_name) WHERE token_type='service'
|
||||
- idx_auth_tokens_runtime_id_unique :: UNIQUE (runtime_id) WHERE runtime_id IS NOT NULL
|
||||
|
||||
### State Rules
|
||||
|
||||
- Service tokens with runtime_id are the only tokens accepted by v1 API endpoints; GET /v1/user_info returns HTTP 404 if runtime_id is None.
|
||||
- Token permission gates both HTTP API guards and CLI mutation capabilities.
|
||||
|
||||
## Entity: `BrokerCredential`
|
||||
|
||||
- Purpose: store Fernet-encrypted broker API credentials per user-broker pair for the trex-basic integration
|
||||
- Source of Truth: broker_credentials table (added by migration 20260502_0014)
|
||||
- Lifecycle: upserted via CLI credential command -> optionally rotated
|
||||
|
||||
### Key Fields
|
||||
|
||||
- user_id :: text :: owning user reference (part of composite PK)
|
||||
- broker :: text :: broker identifier, e.g., "KIS" (part of composite PK)
|
||||
- account_ref :: text :: broker account reference or alias
|
||||
- encrypted_payload :: text :: Fernet-encrypted JSON ciphertext; plaintext is `{"app_key": ..., "app_secret": ...}`; decrypted at query time using CREDENTIAL_ENCRYPTION_KEY (REQ-010)
|
||||
- credential_version :: integer :: monotonically increasing rotation counter; server_default=1
|
||||
- last_rotated_at :: timestamptz :: last credential upsert or rotation timestamp; server_default=NOW()
|
||||
|
||||
### Uniqueness Constraints
|
||||
|
||||
- PRIMARY KEY (user_id, broker) :: one credential entry per user per broker
|
||||
|
||||
### State Rules
|
||||
|
||||
- encrypted_payload must never contain plaintext credentials; Fernet encryption is mandatory before persistence.
|
||||
- Decryption failures (wrong key, corrupted payload) log a warning and return `credential_payload: null` in GET /v1/user_info.
|
||||
|
||||
## Entity: `LedgerTrade`
|
||||
|
||||
- Purpose: record idempotent trade execution entries submitted by trex-basic via POST /v1/assets/{asset_id}/ledger/trades
|
||||
- Source of Truth: ledger_trades table (added by migration 20260502_0014)
|
||||
- Lifecycle: inserted once per unique (asset_id, order_id, execution_id) tuple; never updated or deleted
|
||||
|
||||
### Key Fields
|
||||
|
||||
- ledger_entry_id :: bigint (Identity) :: auto-generated sequential primary key
|
||||
- asset_id :: text :: portfolio_id (UUID-format text after BAT-006) referencing the trading portfolio
|
||||
- order_id :: text :: broker-assigned order identifier
|
||||
- execution_id :: text :: broker-assigned execution/fill identifier
|
||||
- side :: text :: "buy" or "sell"
|
||||
- quantity :: numeric(20,8) :: executed quantity
|
||||
- price :: numeric(20,8) :: execution price
|
||||
- fee_amount :: numeric(20,8) :: brokerage fee; server_default=0
|
||||
- tax_amount :: numeric(20,8) :: transaction tax; server_default=0
|
||||
- executed_at :: timestamptz :: broker-reported execution timestamp
|
||||
- recorded_at :: timestamptz :: insertion timestamp; server_default=NOW()
|
||||
|
||||
### Uniqueness Constraints
|
||||
|
||||
- uq_ledger_trades_idempotency :: UNIQUE (asset_id, order_id, execution_id) :: idempotency guarantee; duplicate inserts are detected and return HTTP 200 + status="duplicate"
|
||||
- idx_ledger_trades_asset_executed :: INDEX (asset_id, executed_at) :: time-range query optimization
|
||||
|
||||
### State Rules
|
||||
|
||||
- Rows are immutable after insertion; no UPDATE path exists.
|
||||
- Payload conflict detection: if unique key matches but payload differs → HTTP 409 Conflict.
|
||||
- asset_id is TEXT (not a FK) but must correspond to a valid portfolios.portfolio_id before insertion.
|
||||
|
||||
## Shared Consistency Rules
|
||||
|
||||
- auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run.
|
||||
- strategy_sets.name plus config, strategy_sets.nick, and service token names have uniqueness constraints.
|
||||
- Portfolio snapshot fields and portfolio_ledger history must stay synchronized across mutations.
|
||||
- (owner_user_id, name) uniqueness is enforced at both the DB constraint level and the application layer; violations surface as a 409 Conflict or ValueError with a Korean message.
|
||||
|
||||
## Persistence / Integration Notes
|
||||
|
||||
@@ -110,4 +204,6 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary.
|
||||
- 2026-05-11 KST: bat-006 — portfolios.portfolio_id changed to server-generated UUID PK; name column added with UNIQUE (owner_user_id, name) constraint; uq_portfolios_owner_name constraint documented; state rules updated.
|
||||
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|
||||
@@ -141,6 +141,7 @@
|
||||
- A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항)
|
||||
- A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항)
|
||||
- A-3: `GET /v1/assets`의 `asset_id`는 기존 `portfolio_id`를 그대로 사용한다. 별도 asset 식별자 체계를 만들지 않는다.
|
||||
- **[철회 — 2026-05-11 REQ-018]**: REQ-016 UUID 마이그레이션 완료 이후 `portfolio_id`는 server-generated UUID로 변경된다. 따라서 `asset_id == portfolio_id (text)` 전제는 `asset_id == portfolio_id (UUID)`로 대체되었다. 가정 A-3의 "별도 asset 식별자 체계를 만들지 않는다"는 원칙은 유지되지만, 실제 값 형식이 UUID로 바뀐다는 점에서 원문 가정의 텍스트 동일성 가정은 철회한다.
|
||||
- A-4: `trading_enabled`는 현재 단계에서 `users.is_active`와 연동하는 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope다.
|
||||
- A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다.
|
||||
|
||||
|
||||
+40
-35
@@ -1,14 +1,18 @@
|
||||
# 0. 문서 상태
|
||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||
|
||||
- 상태: draft
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution
|
||||
- 이슈명: portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환
|
||||
- 작성 시각(KST): 2026-05-11 00:00
|
||||
- 마지막 갱신 시각(KST): 2026-05-11 00:00
|
||||
- 마지막 갱신 시각(KST): 2026-05-11 (draft-req 완료 — REQ-016~019 생성)
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 생성된 REQ 참조: 없음
|
||||
- 생성된 REQ 참조:
|
||||
- [REQ-016](../srs/Migration/req-016_portfolios-uuid-schema-migration.md) — portfolios 테이블 UUID 전환 및 데이터 마이그레이션 (FR-1, FR-5, NFR-1)
|
||||
- [REQ-017](../srs/Interface/req-017_portfolio-cli-name-user-identification.md) — CLI portfolio 명령 name+user 조합 식별 방식 전환 (FR-2, FR-3, FR-3a, NFR-2)
|
||||
- [REQ-018](../srs/Interface/req-018_portfolio-http-api-uuid-path-param.md) — HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 (FR-4)
|
||||
- [REQ-019](../srs/Documentation/req-019_portfolio-uuid-baseline-docs-update.md) — portfolio UUID 전환 관련 baseline 문서 갱신 (FR-6, NFR-3)
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||
@@ -50,7 +54,7 @@
|
||||
- 제약사항
|
||||
- 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수).
|
||||
- dcy-002 A-3("asset_id는 portfolio_id를 그대로 사용")은 이번 Discovery에서 철회된다. trex-basic 팀에게 breaking change를 사전 통보해야 한다.
|
||||
- `name`은 owner 내에서 유일하되, 허용 문자 범위(공백, 특수문자 등)는 REQ 단계에서 결정한다.
|
||||
- `name`은 owner 내에서 유일하며, 허용 문자는 `[a-zA-Z0-9._:-]`, 공백 불허, 1~100자, 대소문자 구분으로 확정됐다 (DATA_NAME_ALLOWED_CHARS Answered 2026-05-11).
|
||||
|
||||
## 주요 사용자 시나리오
|
||||
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
|
||||
@@ -96,6 +100,7 @@
|
||||
- FR-1: `portfolios.portfolio_id`를 서버 생성 UUID(TEXT)로 교체한다. 기존 사용자 지정 portfolio_id 문자열은 `portfolios.name` TEXT 컬럼으로 이전한다. `(owner_user_id, name)` 복합 UNIQUE 제약을 DB 레벨에서 보장한다.
|
||||
- FR-2: `portfolio create` CLI 명령은 `name` Argument와 `--user` 옵션을 필수로 받고, `portfolio_id`는 서버가 UUID로 발급한다. 사용자는 UUID를 직접 지정하거나 조회할 수 없다.
|
||||
- FR-3: CLI 뮤테이션 명령(`status`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`, `update`)은 `<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-5: 기존 데이터 마이그레이션 — 현재 `portfolio_id` 문자열 값을 `name` 컬럼으로 복사하고, 각 레코드에 새 UUID를 `portfolio_id`로 할당한다. `portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades` 등 FK 참조 테이블의 portfolio_id도 새 UUID로 함께 갱신한다.
|
||||
- FR-6: `docs/project-structure.md`와 `docs/runtime-flows.md`를 이번 변경 내용에 맞게 갱신한다(repositories/services 인터페이스 변경, CLI 흐름, v1 API 흐름 변경 반영).
|
||||
@@ -105,7 +110,7 @@
|
||||
|
||||
- NFR-1: 데이터 마이그레이션은 단일 Alembic revision으로 수행하며, IF NOT EXISTS 패턴을 준수해 재실행 시 오류가 없어야 한다(REQ-002 준수). FK 참조 테이블과의 갱신은 트랜잭션 내에서 atomic하게 처리한다.
|
||||
- NFR-2: CLI `(owner_user_id, name)` → UUID 조회는 단일 DB 쿼리로 처리하며, 조회 실패 시 명확한 오류 메시지(`포트폴리오를 찾을 수 없습니다: {name} (user: {username})`)를 출력해야 한다.
|
||||
- NFR-3: trex-basic 계약 변경(asset_id UUID 전환)은 배포 전에 trex-basic 팀에게 통보하고, 변경 일정을 docs/interface-contract.md에 기록해야 한다.
|
||||
- NFR-3: trex-basic 계약 변경(asset_id UUID 전환)은 portfolio-hub와 trex-basic을 동시에 배포하며 처리한다. 변경 일정 및 동시 배포 계획을 docs/interface-contract.md에 기록해야 한다.
|
||||
|
||||
## 범위 경계
|
||||
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
|
||||
@@ -122,8 +127,8 @@
|
||||
- Out of Scope
|
||||
- trex-basic 측 코드 변경 — portfolio-hub API 변경만 담당
|
||||
- `name` 변경 이력 감사 로그 — 향후 반복으로 Deferred
|
||||
- `name` 허용 문자 유효성 검사 정책 — REQ 단계에서 결정
|
||||
- HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 — {{OUT_OF_SCOPE_API_NAME_LOOKUP}} 결정 필요
|
||||
- `name` 허용 문자 유효성 검사 정책 — 확정됨. `[a-zA-Z0-9._:-]` 허용, 공백 불허, 1~100자, 대소문자 구분 (2026-05-11)
|
||||
- HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가 — 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회 (OQ-3 Answered 2026-05-11)
|
||||
|
||||
# 6. 리스크/가정 목록
|
||||
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
|
||||
@@ -132,9 +137,9 @@
|
||||
- R-1 (High): 기존 FK 참조 테이블(`portfolio_ledger`, `portfolio_comments`, `portfolio_tags`, `ledger_trades`)의 portfolio_id를 새 UUID로 일괄 갱신하는 마이그레이션 중 오류 발생 시 데이터 불일치가 생길 수 있다.
|
||||
- 완화 방안: 마이그레이션 전 DB 전체 백업 수행. 마이그레이션 스크립트를 단일 트랜잭션으로 작성하고, 검증 쿼리(FK 고아 레코드 0건 확인)를 마이그레이션 스크립트 마지막에 실행한다.
|
||||
- R-2 (High): `GET /v1/assets` `asset_id`가 UUID로 바뀌면 trex-basic 데몬의 `POST /v1/assets/{asset_id}/ledger/trades` 경로가 즉시 동작하지 않는다. 배포 타이밍 불일치 시 운영 장애가 발생한다.
|
||||
- 완화 방안: trex-basic 팀과 변경 일정 사전 조율. portfolio-hub 배포 전 trex-basic 코드 업데이트가 선행되거나 동시 배포한다. 임시 호환성 레이어(기존 name으로 fallback 조회) 필요 여부를 REQ 단계에서 결정한다(OQ-1).
|
||||
- R-3 (Medium): `name`을 mutable로 허용하면 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨진다.
|
||||
- 완화 방안: name을 immutable(생성 후 변경 불가)로 정책을 확정하거나, 변경 시 경고와 함께 명시적 확인 절차를 요구한다. 결정은 OQ-2에서 처리한다.
|
||||
- 완화 방안: trex-basic과 portfolio-hub를 동시에 배포한다(OQ-1 Answered). 임시 호환 레이어 불필요. 두 저장소의 배포 파이프라인에서 릴리즈 타이밍을 조율한다.
|
||||
- R-3 (Medium): `name`이 mutable이므로 CLI 스크립트나 외부 참조에서 portfolio를 name으로 식별하던 코드가 name 변경 후 깨질 수 있다.
|
||||
- 완화 방안: name 변경 CLI 명령에 명시적 확인 절차(--force 또는 confirm 프롬프트)를 요구한다. DB의 `(owner_user_id, name)` UNIQUE 제약이 같은 사용자 내 중복 name 변경을 자동으로 차단한다(OQ-2 Answered).
|
||||
|
||||
## 가정
|
||||
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
|
||||
@@ -142,23 +147,23 @@
|
||||
- A-1: `portfolio_id`(UUID)는 서버에서 `uuid.uuid4()`로 생성하며, 사용자가 직접 지정하거나 예측할 수 없다.
|
||||
- A-2: `name`은 `(owner_user_id, name)` 복합 UNIQUE 제약을 만족하면 되며, 전역 유일성은 요구하지 않는다.
|
||||
- A-3: 기존 `portfolio_id` 문자열(`005930:tv` 형식)은 데이터 마이그레이션 시 `name` 컬럼 값으로 그대로 이전한다. 변환 없이 복사한다.
|
||||
- A-4: HTTP API 경로에서 `(username, name)` 조합 조회 엔드포인트 추가는 이번 반복 Out of Scope로 잠정 처리하고, CLI 경로만 먼저 전환한다.
|
||||
- A-4: HTTP API 경로에서 `(username, name)` 조합 조회 엔드포인트는 추가하지 않는다. HTTP API는 `portfolio_id`(UUID)만으로 portfolio를 식별한다. CLI만 `(owner_user_id, name)` → UUID 조회 경로를 사용한다(OQ-3 Answered 2026-05-11).
|
||||
|
||||
## 오픈 질문
|
||||
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
|
||||
|
||||
- OQ-1: trex-basic 계약 호환성 전략 — `GET /v1/assets` asset_id가 UUID로 바뀔 때 trex-basic 측의 대응 준비 일정이 확정되기 전에 portfolio-hub를 먼저 배포할 수 있는지, 아니면 임시 호환 레이어(name으로 fallback 조회)가 필요한지.
|
||||
- 상태: Open
|
||||
- 처리 방안: trex-basic 팀과 협의 후 결정. 배포 순서 또는 호환 레이어 필요 여부를 REQ 단계 진입 전에 확정한다.
|
||||
- 종료 조건: trex-basic 팀 확인 완료 또는 임시 호환 레이어 구현 방안 확정 시 Answered 처리.
|
||||
- 상태: Answered
|
||||
- 처리 방안: trex-basic과 portfolio-hub를 동시에 변경·배포한다. 임시 호환 레이어 불필요.
|
||||
- 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
|
||||
- OQ-2: `name` mutable 정책 — 생성 이후 name 변경을 허용할지 여부. 허용할 경우 기존 CLI 스크립트 참조 안정성 확보 방안.
|
||||
- 상태: Open
|
||||
- 처리 방안: 사용자(Justin) 결정. 단순성을 위해 immutable을 기본 제안으로 두고 확인한다.
|
||||
- 종료 조건: 사용자 확인 후 Answered 처리. 결과를 NFR 또는 FR에 반영.
|
||||
- 상태: Answered
|
||||
- 처리 방안: name 변경 허용. 단, 동일 사용자 내 같은 name이 존재하면 DB의 `(owner_user_id, name)` UNIQUE 제약으로 변경 거부. FR-3a에 반영 완료.
|
||||
- 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
|
||||
- OQ-3: HTTP API에서 `(username, name)` 조합으로 portfolio를 조회하는 엔드포인트 추가 여부 — 외부 API 소비자가 UUID를 직접 관리해야 하는 부담을 줄이기 위해 `/portfolios?user=alice&name=tv` 형태의 조회 경로가 필요한지.
|
||||
- 상태: Open
|
||||
- 처리 방안: 사용자(Justin) 결정. 현재 trex-basic은 GET /v1/assets로 UUID를 조회하므로 별도 엔드포인트 불필요할 수 있다.
|
||||
- 종료 조건: 사용자 확인 후 Answered 또는 Deferred 처리.
|
||||
- 상태: Answered
|
||||
- 처리 방안: 추가하지 않음. HTTP API는 `portfolio_id`(UUID)만으로 조회. A-4, Out of Scope 반영 완료.
|
||||
- 종료 조건: 완료 — 2026-05-11 사용자 확인으로 Answered 처리.
|
||||
|
||||
# 7. 초기 성공 기준
|
||||
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
|
||||
@@ -179,15 +184,15 @@
|
||||
# 8. REQ로 넘기기 전 확인 체크
|
||||
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
|
||||
|
||||
- [ ] 범위(In Scope/Out of Scope) 확정
|
||||
- [x] 범위(In Scope/Out of Scope) 확정
|
||||
- 기준: OQ-3(HTTP API name 조회 엔드포인트) 결정 완료, Out of Scope 경계 승인
|
||||
- [ ] 성공 지표의 측정 방식/데이터 출처 확정
|
||||
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||
- 기준: S-1~S-4 측정 방식이 pytest 또는 코드 리뷰로 추적 가능한 형태로 기술됨
|
||||
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||
- 기준: R-1(마이그레이션 데이터 무결성), R-2(trex-basic 계약 파괴) 완화 방안 확정 및 담당자 지정
|
||||
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||
- 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료
|
||||
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- 기준: Justin 또는 지정된 승인자가 confirm-discovery skill로 Freeze 확정
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
@@ -207,15 +212,15 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
||||
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
|
||||
|
||||
## DECIDE
|
||||
- {{DECIDE_NAME_MUTABILITY}}: `name` 컬럼의 변경 허용 여부 — immutable(생성 후 변경 불가) 또는 mutable(변경 허용, 단 확인 절차 필요) 중 선택 (OQ-2)
|
||||
- {{DECIDE_TREX_COMPAT_STRATEGY}}: trex-basic 계약 호환성 전략 — portfolio-hub 단독 배포 후 trex-basic 후속 배포 허용 여부, 또는 임시 호환 레이어(name fallback 조회) 구현 필요 여부 (OQ-1)
|
||||
- ~~{{DECIDE_NAME_MUTABILITY}}~~: **Answered** — name mutable 허용. 동일 사용자 내 중복 name 변경은 DB UNIQUE 제약으로 거부. FR-3a 반영 완료. (OQ-2 2026-05-11)
|
||||
- ~~{{DECIDE_TREX_COMPAT_STRATEGY}}~~: **Answered** — trex-basic과 portfolio-hub 동시 배포로 확정. 임시 호환 레이어 불필요. (OQ-1 2026-05-11)
|
||||
|
||||
## CONFIRM
|
||||
- {{CONFIRM_API_NAME_LOOKUP_SCOPE}}: HTTP API에서 `(username, name)` 조합 조회 엔드포인트 추가가 이번 반복 Out of Scope임을 확인 (OQ-3)
|
||||
- {{CONFIRM_MIGRATION_STRATEGY}}: 기존 portfolio_id 문자열을 name으로 변환 없이 그대로 복사하는 마이그레이션 전략 확인 (A-3)
|
||||
- ~~{{CONFIRM_API_NAME_LOOKUP_SCOPE}}~~: **Answered** — `(username, name)` 조합 엔드포인트 추가 안 함. HTTP API는 `portfolio_id`(UUID)만으로 조회. (OQ-3 2026-05-11)
|
||||
- ~~{{CONFIRM_MIGRATION_STRATEGY}}~~: **Answered** — 기존 `portfolio_id` 문자열을 `name` 컬럼으로 변환 없이 그대로 복사. (A-3 2026-05-11)
|
||||
|
||||
## DATA
|
||||
- {{DATA_NAME_ALLOWED_CHARS}}: `name` 컬럼에 허용할 문자 범위 정의 — 공백 허용 여부, 특수문자 허용 범위, 최대 길이 제한 등 (REQ 단계 결정 필요)
|
||||
- ~~{{DATA_NAME_ALLOWED_CHARS}}~~: **Answered** — `name` 허용 문자 `[a-zA-Z0-9._:-]`(영숫자 + `.` `_` `:` `-`), 공백 불허, 1~100자, 대소문자 구분 있음. (2026-05-11)
|
||||
|
||||
# 11. Discovery Freeze
|
||||
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
|
||||
@@ -228,8 +233,8 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록
|
||||
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: 보류
|
||||
- Handoff Rationale: 초기 Discovery 초안 생성 완료. OQ-1(trex-basic 호환성), OQ-2(name mutable 여부), OQ-3(HTTP API name 조회 엔드포인트) 3개가 Open 상태이며 REQ 초안 작성 전 사용자 확인 필요.
|
||||
- Ready for REQ Drafting: false
|
||||
- Confirmed By: TBD
|
||||
- Confirmed At (KST): TBD
|
||||
- Handoff Decision: 승인
|
||||
- Handoff Rationale: 모든 OQ Answered 완료, 모든 CONFIRM/DATA 항목 확정. confirm-discovery 게이트 통과 — REQ 초안 작성으로 인계 가능.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: Justin
|
||||
- Confirmed At (KST): 2026-05-11
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed |
|
||||
| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed |
|
||||
| dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.md) | confirmed |
|
||||
| dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution | 20260511 | portfolio_id UUID 교체 및 CLI 사용자 범위 기반 자산 식별 구조 전환 | [Discovery](./dcy-004_20260511_portfolio-id-uuid-migration-and-user-scoped-name-resolution.md) | draft |
|
||||
| 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
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-04-30 22:13
|
||||
- Source Discovery / Batch: bootstrap-baseline
|
||||
- Last Updated (KST): 2026-05-11 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008
|
||||
- Primary Runtime: mixed
|
||||
|
||||
## Project Summary
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
- JSON response models :: responses are shaped by app/schemas/auth.py, app/schemas/user.py, and app/schemas/portfolio.py
|
||||
- HTTP 201, 200, and 204 status codes :: create, read or update, and delete semantics respectively
|
||||
- GET /health payload :: returns {"status": "ok"} for readiness checks
|
||||
- GET /health payload :: returns `{"status": "ok", "service": "portfolio-hub", "version": "<semver>", "checked_at": "<ISO8601 KST>"}` for readiness checks
|
||||
|
||||
### Error Contract
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
- pfhub subcommands :: include user, portfolio, strategy, service-token, health-check, init-db, and migrate-db command families
|
||||
- Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments
|
||||
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
|
||||
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -91,6 +92,136 @@
|
||||
- Write commands require explicit confirmation unless --force is present.
|
||||
- JSON output should remain machine-readable across additive command evolution.
|
||||
|
||||
## Interface: `v1 Consumer API (trex-basic)`
|
||||
|
||||
- Type: http-api
|
||||
- Consumers / Producers: trex-basic service (Consumer-Driven Contract, dcy-002)
|
||||
- Purpose: provide asset listing, user info, and trade ledger recording for the trex-basic trading daemon
|
||||
- Stability: internal (versioned under /v1)
|
||||
|
||||
### Authentication
|
||||
|
||||
- All /v1 endpoints require `Authorization: Bearer <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
|
||||
|
||||
- Token permission levels gate both HTTP and CLI mutations consistently.
|
||||
@@ -111,4 +242,6 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract.
|
||||
- 2026-05-11 KST: bat-006 — Added v1 consumer API interface section (trex-basic); noted asset_id UUID change and simultaneous deployment constraint; updated CLI portfolio inputs to reflect name+user identification.
|
||||
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|
||||
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-05-01 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001
|
||||
- Last Updated (KST): 2026-05-11 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007
|
||||
|
||||
## Project Summary
|
||||
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
|
||||
@@ -95,6 +95,7 @@
|
||||
- Holds framework-light domain models shared by services, routes, and repositories.
|
||||
- app/repositories/
|
||||
- Provides the repository protocol, repository factory, and concrete Postgres persistence implementation.
|
||||
- Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands.
|
||||
- app/schemas/
|
||||
- Defines request and response schemas for HTTP contracts.
|
||||
- `v1.py`: Contract schemas for v1 API (AssetItem, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response).
|
||||
@@ -122,6 +123,30 @@
|
||||
- docs/ holds SDLC source-of-truth artifacts, while temp/ remains scratch or legacy material outside the active document flow.
|
||||
- .vendor/stage-pilot/ is a vendored upstream toolkit and should not be treated as the active host source of truth.
|
||||
|
||||
## Schema Ownership Boundary
|
||||
|
||||
`app/repositories/postgres.py`의 `ensure_schema()`와 `migrations/versions/` Alembic revision 양쪽이 일부 DB 객체를 다룬다.
|
||||
|
||||
### 소유 경계
|
||||
|
||||
| 소유 경로 | 관리 객체 | 비고 |
|
||||
|-----------|-----------|------|
|
||||
| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index) | Alembic revision에 없음 |
|
||||
| Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 |
|
||||
| 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic 실행 시 충돌 없음 |
|
||||
|
||||
### 허용 실행 순서
|
||||
|
||||
1. `init-db` 선행 → App 시작 → 정상 운영 (권장)
|
||||
2. App 시작 (ensure_schema() 선행) → `migrate-db` 후속 실행 (legacy stamp 경로)
|
||||
3. 이미 Alembic 관리 DB → `migrate-db` 증분 upgrade
|
||||
|
||||
### 운영 제약
|
||||
|
||||
- ensure_schema()만 실행한 환경은 v1 API 스키마(`broker_credentials`, `ledger_trades`, `runtime_id`)가 없어 v1 엔드포인트가 동작하지 않는다. `migrate-db`로 보완 필요.
|
||||
- `alembic_version` 테이블을 수동으로 삭제한 뒤 `migrate-db`를 재실행하면 legacy stamp 오판이 발생할 수 있다.
|
||||
- legacy DB(bot_principals 등 구 revision 0006~0013 구조)와 신규 revision 간 호환성 검증은 이번 batch 범위 밖이다.
|
||||
|
||||
## Current Gaps / Planned Changes
|
||||
|
||||
- The non-DB repository path intentionally raises a feature-not-ready error when persistence is disabled.
|
||||
@@ -136,4 +161,6 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-11 KST: bat-007 — Added "Schema Ownership Boundary" section documenting ensure_schema() vs Alembic ownership zones, allowed/forbidden execution sequences, and legacy scope exclusion.
|
||||
- 2026-05-11 KST: bat-006 — Added get_portfolio_by_name_and_user() to repositories section; noted that portfolio_id is now a server-generated UUID.
|
||||
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|
||||
+61
-2
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-05-01 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001
|
||||
- Last Updated (KST): 2026-05-11 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008
|
||||
- Primary Runtime: mixed
|
||||
|
||||
## Project Summary
|
||||
@@ -50,6 +50,17 @@
|
||||
4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied.
|
||||
5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
|
||||
|
||||
## Flow: `CLI Portfolio Command — Name+User Resolution`
|
||||
|
||||
1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `<name>` positional argument and `--user <username>` option.
|
||||
2. `_resolve_portfolio_id(repository, name, user)` is called before any mutation:
|
||||
a. Calls `get_user_by_username(user)` → raises ValueError if user not found.
|
||||
b. Calls `get_portfolio_by_name_and_user(owner_user_id, name)` → raises ValueError if portfolio not found.
|
||||
c. Returns `portfolio.portfolio_id` (UUID) for downstream use.
|
||||
3. `create` command takes `<name>` as the portfolio identifier; portfolio_id (UUID) is server-generated and returned in the response.
|
||||
4. `update --new-name <new_name>` renames the portfolio; `--force` is required to confirm the rename.
|
||||
5. `comment` and `tag` sub-commands continue to accept `<portfolio_id>` (UUID) directly; they are outside the name+user resolution flow.
|
||||
|
||||
## Flow Constraints
|
||||
|
||||
- Persistence-dependent HTTP and CLI flows require PORTFOLIOHUB_DB_ENABLED=true plus valid TimescaleDB connection settings.
|
||||
@@ -71,6 +82,7 @@
|
||||
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 `{}`.
|
||||
5. Returns `AssetsResponse` with `total` and `assets[]` using new field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
|
||||
6. `asset_id` in each asset item equals `portfolio.portfolio_id` which is a server-generated UUID after BAT-006 migration (dcy-002 A-3 updated: text identity assumption replaced by UUID).
|
||||
|
||||
## Flow: `v1 API — GET /v1/user_info`
|
||||
|
||||
@@ -102,6 +114,50 @@
|
||||
2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`.
|
||||
3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup.
|
||||
|
||||
## Flow: `Local DB Initialization and Validation`
|
||||
|
||||
### Scenario A — Fresh DB (DB 미존재 상태)
|
||||
|
||||
1. `docker compose -f docker-compose.local-test.yml up -d` — TimescaleDB 컨테이너 시작.
|
||||
2. 컨테이너 준비 확인: `docker compose -f docker-compose.local-test.yml ps` → `portfolio-hub-timescaledb` 상태가 `healthy` 인지 확인.
|
||||
3. `pfhub_DB_ENABLED=true pfhub init-db` — 대상 DB 생성 + Alembic `upgrade head` 실행.
|
||||
- 기대 출력: `database: portfolio_hub`, `created: True`, `schema_initialized: True`, `migration_applied: head`
|
||||
- pass 기준: exit code 0, 위 필드 모두 출력
|
||||
- fail 기준: connection refused, permission denied, Alembic revision 오류
|
||||
4. `pfhub_DB_ENABLED=true pfhub health-check` — DB ping 성공 확인.
|
||||
- 기대 출력: `db_enabled: True`, `repository: PostgresPortfolioRepository`
|
||||
- pass 기준: exit code 0, `db_enabled: True`
|
||||
5. Smoke flow: 사용자 생성 → portfolio 생성 → `portfolio list --user <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
|
||||
|
||||
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
|
||||
@@ -110,5 +166,8 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated.
|
||||
- 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow.
|
||||
- 2026-05-11 KST: bat-006 — Added CLI portfolio name+user resolution flow; updated GET /v1/assets flow to note asset_id is UUID after BAT-006 migration.
|
||||
- 2026-05-01 KST: bat-001 — Added v1 API flows (service-token auth, GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens), startup validation flow.
|
||||
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|
||||
@@ -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 응답 스키마 확장
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -32,6 +32,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC2(degraded/failed 경로)는 REQ Notes에서 이번 반복 OOS로 명시 — 릴리스 블로커 아님.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001 기반 release 편성 검토)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-005: service-token 발급 시 runtime_id 동시 생성
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -34,6 +34,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC5 DB 미확인은 REQ-009와 동일 제약으로 non-blocker 처리.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-006: GET /v1/assets 엔드포인트 구현
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -39,6 +39,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03, CHG-20260503-01 strategy_type 재검증 포함) AC-1~9 모두 PASS. Blocking Issues 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~9 evidence 연결 확인 완료 (strategy_type 필드 재검증 포함)
|
||||
|
||||
### CHG-20260503-01
|
||||
- Date: 2026-05-03
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-007: GET /v1/user_info 엔드포인트 구현
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -38,6 +38,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~7 모두 PASS. Blocking Issues 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~7 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -36,6 +36,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -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 추가
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Migration
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -39,6 +39,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~7 모두 코드 검토 PASS. DB 환경 실행 미확인은 non-blocker로 처리됨 (revision에 IF NOT EXISTS 패턴 전체 적용 확인). Blocking Issues 없음.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001); 배포 전 Docker Compose 환경에서 실제 migration 실행 검증 권장
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~7 코드 검토 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
@@ -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
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Non-Functional
|
||||
- Priority: High
|
||||
- Owner: repository-maintainers
|
||||
@@ -35,6 +35,35 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260511-01
|
||||
- Date: 2026-05-11
|
||||
- Author: repository-maintainers
|
||||
|
||||
#### Change Summary
|
||||
- Implemented 전환 — BAT-001 re-verified verification passed, confirm-req-implemented 게이트 통과
|
||||
|
||||
#### Intent
|
||||
- BAT-001 verification.md (re-verified, 2026-05-03) AC-1~5 모두 PASS. Blocking Issues 없음. AC3 DB 미확인은 REQ-009와 동일 제약으로 non-blocker 처리.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- none
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-001 (release-candidate)
|
||||
- Releases: none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action: draft-release (BAT-001)
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-001 verification.md (re-verified) AC-1~5 evidence 연결 확인 완료
|
||||
|
||||
### CHG-20260502-02
|
||||
- Date: 2026-05-02
|
||||
- Author: repository-maintainers
|
||||
|
||||
+17
-16
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-016`
|
||||
- Current: `REQ-020`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
@@ -84,26 +84,27 @@
|
||||
| REQ-001 | 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 | Testing | Approved | High | repository-maintainers | [REQ-001](./Testing/req-001_local-db-validation-scenarios.md) |
|
||||
| REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | Approved | High | repository-maintainers | [REQ-002](./Migration/req-002_alembic-runtime-schema-ownership.md) |
|
||||
| REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-003](./Documentation/req-003_local-db-operations-runbook-and-baseline-updates.md) |
|
||||
| REQ-004 | GET /health 응답 스키마 확장 | Interface | Approved | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) |
|
||||
| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) |
|
||||
| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) |
|
||||
| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) |
|
||||
| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) |
|
||||
| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) |
|
||||
| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) |
|
||||
| REQ-004 | GET /health 응답 스키마 확장 | Interface | Implemented | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) |
|
||||
| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Implemented | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) |
|
||||
| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) |
|
||||
| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) |
|
||||
| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Implemented | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) |
|
||||
| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Implemented | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) |
|
||||
| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Implemented | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) |
|
||||
| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) |
|
||||
| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) |
|
||||
| REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Implemented | Medium | repository-maintainers | [REQ-013](./Interface/req-013_portfolio-list-user-required.md) |
|
||||
| REQ-014 | CLI `portfolio create --user` 필수화 및 소유자 설정 | Interface | Implemented | Medium | repository-maintainers | [REQ-014](./Interface/req-014_portfolio-create-user-required.md) |
|
||||
| REQ-015 | CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리) | Interface | Approved | Medium | repository-maintainers | [REQ-015](./Interface/req-015_broker-credential-cli.md) |
|
||||
| REQ-016 | portfolios 테이블 UUID 전환 및 데이터 마이그레이션 | Migration | Implemented | High | repository-maintainers | [REQ-016](./Migration/req-016_portfolios-uuid-schema-migration.md) |
|
||||
| REQ-017 | CLI portfolio 명령 — name+user 조합 식별 방식 전환 | Interface | Implemented | High | repository-maintainers | [REQ-017](./Interface/req-017_portfolio-cli-name-user-identification.md) |
|
||||
| REQ-018 | HTTP API portfolio path parameter UUID 전환 및 v1 asset_id UUID 반환 | Interface | Implemented | High | repository-maintainers | [REQ-018](./Interface/req-018_portfolio-http-api-uuid-path-param.md) |
|
||||
| REQ-019 | portfolio UUID 전환 관련 baseline 문서 갱신 | Documentation | Implemented | Medium | repository-maintainers | [REQ-019](./Documentation/req-019_portfolio-uuid-baseline-docs-update.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-05-03: REQ-006 CHG-20260503-01 — strategy_type 필드 추가 (StrategySet.name), BAT-001 재검증 필요
|
||||
- 2026-05-03: REQ-015 Approved 전환 — minor-change fast path (단일 저위험 REQ)
|
||||
- 2026-05-03: REQ-015 Proposed 생성 — CLI `credential` 명령 그룹 구현 (브로커 인증 정보 관리)
|
||||
- 2026-05-02: REQ-014 Implemented 전환 — BAT-004 verification evidence 확인 (44 passed)
|
||||
- 2026-05-02: REQ-014 Approved 직행 (minor-change) — CLI `portfolio create --user` 필수화 및 소유자 설정
|
||||
- 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed)
|
||||
- 2026-05-02: REQ-013 Approved 전환 — CLI `portfolio list --user` 필수화 및 소유자 기반 조회 (source Discovery: dcy-003)
|
||||
- 2026-05-02: REQ-012 Approved 직행 (minor-change) — service-token rotate CLI 명령 구현
|
||||
- 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 재검증 필요
|
||||
@@ -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
@@ -208,12 +208,11 @@ class FakeRepo:
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
name: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
@@ -222,6 +221,7 @@ class FakeRepo:
|
||||
active_strategy_id: str | None,
|
||||
avabile_strategies: list[str] | None,
|
||||
) -> Portfolio:
|
||||
portfolio_id = str(uuid.uuid4())
|
||||
self._portfolio = Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
broker=broker,
|
||||
@@ -238,6 +238,13 @@ class FakeRepo:
|
||||
)
|
||||
return self._portfolio
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
|
||||
if self._portfolio is None:
|
||||
return None
|
||||
if self._portfolio.owner_user_id == owner_user_id and self._portfolio.name == name:
|
||||
return self._portfolio
|
||||
return None
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
portfolio = self.get(portfolio_id)
|
||||
for key, value in fields.items():
|
||||
@@ -484,7 +491,7 @@ def _portfolio() -> Portfolio:
|
||||
portfolio_id="005930:tv",
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="Samsung Electronics",
|
||||
name="005930:tv",
|
||||
symbol="005930",
|
||||
cash_balance=1_000_000.0,
|
||||
asset_quantity=1.0,
|
||||
@@ -511,10 +518,14 @@ def test_cli_list_renders_table() -> None:
|
||||
|
||||
|
||||
def test_cli_status_supports_json_output() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo = FakeRepo()
|
||||
user = repo.create_user("alice", permission="read")
|
||||
p = _portfolio()
|
||||
p.owner_user_id = user.id
|
||||
repo._portfolio = p
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"])
|
||||
result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv", "--user", "alice"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
@@ -549,6 +560,8 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
"portfolio",
|
||||
"buy",
|
||||
"005930:tv",
|
||||
"--user",
|
||||
"alice",
|
||||
"--quantity",
|
||||
"2",
|
||||
"--price",
|
||||
@@ -560,12 +573,12 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
)
|
||||
assert repo.entries[0].action == "BUY"
|
||||
assert repo._portfolio.owner_user_id == user.id
|
||||
delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"])
|
||||
delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--user", "alice", "--keep-ledger", "--force"])
|
||||
|
||||
assert create_result.exit_code == 0
|
||||
assert buy_result.exit_code == 0
|
||||
assert delete_result.exit_code == 0
|
||||
assert "Deleted portfolio: 005930:tv" in delete_result.stdout
|
||||
assert "Deleted portfolio: 005930:tv" in delete_result.stdout # name used as identifier
|
||||
|
||||
|
||||
def test_cli_write_command_prompts_before_create() -> None:
|
||||
@@ -590,7 +603,7 @@ def test_cli_write_command_prompts_before_create() -> None:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Create portfolio 005930:tv for symbol 005930?" in result.stdout
|
||||
assert "Create portfolio '005930:tv' for symbol 005930 (user: alice)?" in result.stdout
|
||||
|
||||
|
||||
def test_cli_write_command_can_abort() -> None:
|
||||
@@ -627,8 +640,8 @@ def test_cli_portfolio_create_without_user_exits_error() -> None:
|
||||
["portfolio", "create", "005930:tv", "--symbol", "005930", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--user" in result.stdout
|
||||
assert result.exit_code != 0
|
||||
assert "--user" in result.output
|
||||
|
||||
|
||||
def test_cli_portfolio_create_with_valid_user_sets_owner() -> None:
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N
|
||||
response = client.post(
|
||||
"/portfolios",
|
||||
headers={"Authorization": "Bearer trade-env-token"},
|
||||
json={"portfolio_id": "new-1", "symbol": "005930"},
|
||||
json={"name": "new-1", "symbol": "005930"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user