feat: implement portfolio UUID migration and v1 API finalization (BAT-006, BAT-001 re-verified)

Core Changes:
- Migrate portfolio_id from string to UUID in schema and repository layer
- Update portfolio CLI commands to use name+user combination for identification
- Update HTTP API to accept portfolio_id as UUID path parameter
- Implement portfolio ownership validation in repository queries

Requirements Completed (REQ-016 to REQ-019):
- REQ-016: portfolios table UUID schema migration with data migration script
- REQ-017: CLI portfolio commands with name+user identification
- REQ-018: HTTP API portfolio path parameters and v1 asset_id UUID returns
- REQ-019: Portfolio UUID baseline documentation updates

BAT-001 Re-verification:
- REQ-004~010 marked as Implemented after verification evidence validation
- All v1 API endpoints verified with comprehensive test coverage

Documentation Updates:
- Update data-model.md with portfolio UUID structure
- Update interface-contract.md with UUID path parameters
- Update runtime-flows.md with ownership-based resolution flows
- Update project-structure.md with database layer changes
- Add migration script for portfolio UUID transition

Testing:
- Add test_portfolio_uuid.py for migration validation
- Update test_cli.py and test_portfolio_ownership.py for ownership checks
This commit is contained in:
2026-05-11 09:27:57 +00:00
parent 9ed307555a
commit 829629fa8f
46 changed files with 3260 additions and 220 deletions
+123 -80
View File
@@ -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()}")