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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user