feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Asset catalog CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import execute_command
|
||||
|
||||
asset_app = typer.Typer(help="Asset catalog management commands.")
|
||||
|
||||
|
||||
def _with_repo(ctx: typer.Context, callback):
|
||||
app_context = ctx.obj
|
||||
repository = app_context.repository_factory(app_context.settings)
|
||||
try:
|
||||
return execute_command(app_context.console, callback=lambda: callback(repository, app_context))
|
||||
finally:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _confirm(force: bool, message: str) -> None:
|
||||
if force:
|
||||
return
|
||||
if not typer.confirm(message):
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
|
||||
def _asset_to_dict(asset) -> dict:
|
||||
return {
|
||||
"asset_id": asset.asset_id,
|
||||
"symbol": asset.symbol,
|
||||
"markets": asset.markets,
|
||||
"display_name": asset.display_name,
|
||||
"status": asset.status,
|
||||
"created_at": str(asset.created_at) if asset.created_at else None,
|
||||
"updated_at": str(asset.updated_at) if asset.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@asset_app.command("create")
|
||||
def asset_create(
|
||||
ctx: typer.Context,
|
||||
symbol: str = typer.Argument(..., help="Asset symbol (e.g. 005930, BTC)."),
|
||||
display_name: str | None = typer.Option(None, "--display-name", help="Human-readable name."),
|
||||
market: list[str] | None = typer.Option(None, "--market", help="Market code. Repeatable."),
|
||||
status: str = typer.Option("active", help="Asset status."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Create asset {symbol!r}?")
|
||||
markets = list(market or [])
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.insert_asset(symbol=symbol, markets=markets, display_name=display_name, status=status)
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Created asset: {d['symbol']} {d['asset_id']}")
|
||||
|
||||
|
||||
@asset_app.command("list")
|
||||
def asset_list(
|
||||
ctx: typer.Context,
|
||||
active_only: bool = typer.Option(False, "--active-only", help="Show active assets only."),
|
||||
) -> None:
|
||||
def _callback(repository, app_context):
|
||||
assets = repository.list_assets()
|
||||
if active_only:
|
||||
assets = [a for a in assets if a.status == "active"]
|
||||
return assets
|
||||
|
||||
results = _with_repo(ctx, _callback)
|
||||
items = [_asset_to_dict(a) for a in results] if results else []
|
||||
if not items:
|
||||
ctx.obj.console.print("No assets found.")
|
||||
return
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=items)
|
||||
else:
|
||||
for a in items:
|
||||
display_name = a["display_name"] or ""
|
||||
markets = ",".join(a["markets"]) if a["markets"] else "-"
|
||||
ctx.obj.console.print(
|
||||
f"{a['symbol']} {a['status']} [{markets}] {display_name} {a['asset_id']}"
|
||||
)
|
||||
|
||||
|
||||
@asset_app.command("update")
|
||||
def asset_update(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset UUID."),
|
||||
display_name: str | None = typer.Option(None, "--display-name", help="Updated display name."),
|
||||
market: list[str] | None = typer.Option(None, "--market", help="Updated market codes. Repeatable."),
|
||||
status: str | None = typer.Option(None, help="Updated status."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Update asset {asset_id!r}?")
|
||||
markets = list(market) if market is not None else None
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.update_asset(asset_id, markets=markets, display_name=display_name, status=status)
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Updated asset: {d['symbol']} {d['status']} {d['asset_id']}")
|
||||
|
||||
|
||||
@asset_app.command("deactivate")
|
||||
def asset_deactivate(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset UUID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Deactivate asset {asset_id!r}?")
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.update_asset(asset_id, status="inactive")
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Deactivated asset: {d['symbol']} {d['status']} {d['asset_id']}")
|
||||
@@ -145,7 +145,8 @@ def create_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
asset_id: str = typer.Option(..., "--asset-id", help="Asset UUID from the asset catalog."),
|
||||
asset_id: str | None = typer.Option(None, "--asset-id", help="Asset UUID from the asset catalog."),
|
||||
symbol: str | None = typer.Option(None, "--symbol", help="Asset symbol to look up asset_id (alternative to --asset-id)."),
|
||||
broker: str = typer.Option("kis", help="Broker code."),
|
||||
status: str = typer.Option("enabled", help="Portfolio status."),
|
||||
cash_balance: float = typer.Option(0.0, min=0.0, help="Starting cash balance."),
|
||||
@@ -157,16 +158,24 @@ def create_portfolio(
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Create portfolio {name!r} (asset-id: {asset_id}, user: {user})?")
|
||||
if asset_id is None and symbol is None:
|
||||
ctx.obj.console.print("Error: --asset-id 또는 --symbol 중 하나는 필수입니다.")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
resolved_asset_id = asset_id
|
||||
if resolved_asset_id is None:
|
||||
asset = repository.get_asset_by_symbol(symbol)
|
||||
if asset is None:
|
||||
raise ValueError(f"symbol에 해당하는 asset을 찾을 수 없습니다: {symbol}")
|
||||
resolved_asset_id = asset.asset_id
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
|
||||
return service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name=name,
|
||||
asset_id=asset_id,
|
||||
asset_id=resolved_asset_id,
|
||||
broker=broker,
|
||||
status=status,
|
||||
cash_balance=cash_balance,
|
||||
@@ -179,6 +188,7 @@ def create_portfolio(
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
|
||||
_confirm(force, f"Create portfolio {name!r} (user: {user})?")
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user