feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)

This commit is contained in:
2026-05-19 22:15:55 +00:00
parent e2226e28b6
commit 158929b2d9
33 changed files with 2575 additions and 102 deletions
+185 -63
View File
@@ -1,4 +1,4 @@
"""GET /v1/assets — service-token authenticated asset list endpoint."""
"""v1 asset endpoints — portfolio list (service/user/admin) and admin catalog CRUD."""
from __future__ import annotations
@@ -7,24 +7,22 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.auth import AuthContext, require_read
from app.core.auth import AuthContext, require_admin, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.v1 import AssetItem, AssetsResponse
from app.schemas.v1 import (
AssetCatalogCreateRequest,
AssetCatalogResponse,
AssetCatalogUpdateRequest,
AssetItem,
AssetsResponse,
TagRefItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
def _parse_strategy_params(strategy_set_config: str | None) -> dict:
if not strategy_set_config:
return {}
@@ -35,70 +33,194 @@ def _parse_strategy_params(strategy_set_config: str | None) -> dict:
return {}
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
def _asset_response(asset) -> AssetCatalogResponse:
return AssetCatalogResponse(
asset_id=asset.asset_id,
symbol=asset.symbol,
markets=asset.markets,
display_name=asset.display_name,
status=asset.status,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
def _build_tag_refs(tag_ids: list[str] | None, active_map: dict[str, bool]) -> list[TagRefItem] | None:
if not tag_ids:
return None
return [
TagRefItem(tag_id=tid, archived=not active_map.get(tid, True))
for tid in tag_ids
]
def _build_asset_items(portfolios, status_filter: str | None, repo) -> list[AssetItem]:
# Batch-fetch tag active status for all tag_ids across all portfolios
all_tag_ids: list[str] = [
tid for p in portfolios for tid in (p.tag_ids or [])
]
tag_active_map: dict[str, bool] = {}
if all_tag_ids:
try:
tags_by_id = repo.get_tags_by_ids(all_tag_ids)
tag_active_map = {tid: t.is_active for tid, t in tags_by_id.items()}
except Exception:
logger.warning("tag active status lookup failed")
items = []
for p in portfolios:
if status_filter is not None and p.status != status_filter:
continue
strategy_params: dict = {}
strategy_type: str | None = None
if p.active_strategy_id:
try:
ss = repo.get_strategy_set(p.active_strategy_id)
if ss is not None:
strategy_params = _parse_strategy_params(ss.config)
strategy_type = ss.name
except Exception:
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
items.append(
AssetItem(
asset_id=p.portfolio_id,
symbol=p.symbol,
status=p.status,
cash=p.cash_balance,
quantity=p.asset_quantity,
broker_type=p.broker,
strategy_id=p.active_strategy_id,
strategy_type=strategy_type,
strategy_params=strategy_params,
updated_at=p.updated_at,
name=p.name,
price=p.price,
profit_buffer=p.profit_buffer,
tags=p.tags,
tag_ids=_build_tag_refs(p.tag_ids, tag_active_map),
owner_user_id=p.owner_user_id,
created_at=p.created_at,
last_trade_at=p.last_trade_at,
)
)
return items
# ---------------------------------------------------------------------------
# GET /v1/assets — token-type-branched portfolio list (REQ-013)
# ---------------------------------------------------------------------------
@router.get("/assets", response_model=AssetsResponse)
def list_assets(
status_filter: str | None = Query(default=None, alias="status"),
auth: AuthContext = Depends(_require_service_token),
market: str | None = Query(default=None),
user_id: str | None = Query(default=None),
auth: AuthContext = Depends(require_read),
) -> AssetsResponse:
if auth.daemon_id is None:
if auth.token_source != "db":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id not assigned to this token",
detail="DB token required",
)
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
repo = _get_repo()
try:
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=False,
managed_by_daemon_id=auth.daemon_id,
)
# Fetch strategy_params per portfolio
items = []
for p in portfolios:
# Optional status filter
if status_filter is not None and p.status != status_filter:
continue
strategy_params: dict = {}
strategy_type: str | None = None
if p.active_strategy_id:
try:
ss = repo.get_strategy_set(p.active_strategy_id)
if ss is not None:
strategy_params = _parse_strategy_params(ss.config)
strategy_type = ss.name
except Exception:
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
strategy_params = {}
items.append(
AssetItem(
asset_id=p.portfolio_id,
symbol=p.symbol,
status=p.status,
cash=p.cash_balance,
quantity=p.asset_quantity,
broker_type=p.broker,
strategy_id=p.active_strategy_id,
strategy_type=strategy_type,
strategy_params=strategy_params,
updated_at=p.updated_at,
name=p.name,
price=p.price,
profit_buffer=p.profit_buffer,
tags=p.tags,
owner_user_id=p.owner_user_id,
created_at=p.created_at,
last_trade_at=p.last_trade_at,
if auth.token_type == "service":
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id not assigned to this token",
)
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=False,
managed_by_daemon_id=auth.daemon_id,
market=market,
)
elif auth.token_type == "user":
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=True,
market=market,
)
elif auth.token_type == "admin":
filter_user_id = user_id if user_id is not None else auth.user_id
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=filter_user_id,
include_disabled=True,
market=market,
)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Unsupported token type",
)
items = _build_asset_items(portfolios, status_filter, repo)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return AssetsResponse(assets=items, total=len(items))
# ---------------------------------------------------------------------------
# Admin asset catalog CRUD (REQ-010)
# ---------------------------------------------------------------------------
@router.post("/assets", response_model=AssetCatalogResponse, status_code=201)
def create_catalog_asset(
body: AssetCatalogCreateRequest,
auth: AuthContext = Depends(require_admin),
) -> AssetCatalogResponse:
repo = _get_repo()
try:
asset = repo.insert_asset(
symbol=body.symbol,
markets=body.markets,
display_name=body.display_name,
status=body.status,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return _asset_response(asset)
@router.patch("/assets/{asset_id}/deactivate", response_model=AssetCatalogResponse)
def deactivate_catalog_asset(
asset_id: str,
auth: AuthContext = Depends(require_admin),
) -> AssetCatalogResponse:
repo = _get_repo()
try:
asset = repo.update_asset(asset_id, status="inactive")
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return _asset_response(asset)
@router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse)
def update_catalog_asset(
asset_id: str,
body: AssetCatalogUpdateRequest,
auth: AuthContext = Depends(require_admin),
) -> AssetCatalogResponse:
repo = _get_repo()
try:
asset = repo.update_asset(
asset_id,
markets=body.markets,
display_name=body.display_name,
status=body.status,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return _asset_response(asset)
+130
View File
@@ -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']}")
+13 -3
View File
@@ -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)
+2
View File
@@ -8,6 +8,7 @@ from typing import Callable
import typer
from rich.console import Console
from app.cli.commands.asset import asset_app
from app.cli.commands.daemon import daemon_app
from app.cli.commands.health import register_health_commands
from app.cli.commands.service_tokens import token_app
@@ -49,6 +50,7 @@ def create_app(
repository_factory=repository_factory,
)
app.add_typer(asset_app, name="asset")
app.add_typer(portfolio_app, name="portfolio")
app.add_typer(strategy_app, name="strategy")
app.add_typer(user_app, name="user")
+11
View File
@@ -18,6 +18,16 @@ class MarketCode(str, Enum):
BITHUMB = "BITHUMB"
@dataclass
class Tag:
tag_id: str
name: str
created_by_user_id: str
is_admin_tag: bool = False
is_active: bool = True
created_at: datetime | None = None
@dataclass
class Asset:
asset_id: str
@@ -97,6 +107,7 @@ class Portfolio:
symbol: str
asset_id: str | None = None
tags: list[str] | None = None
tag_ids: list[str] | None = None
owner_user_id: str | None = None
cash_balance: float = 0.0
asset_quantity: float = 0.0
+19 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -181,6 +181,7 @@ class PortfolioRepository(Protocol):
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]: ...
def list_portfolios_with_strategies(
@@ -189,8 +190,25 @@ class PortfolioRepository(Protocol):
*,
include_disabled: bool = True,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]: ...
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag: ...
def get_tag_by_id(self, tag_id: str) -> Tag | None: ...
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]: ...
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]: ...
def deactivate_tag(self, tag_id: str) -> Tag: ...
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
+120 -2
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -62,11 +62,23 @@ _SELECT_PORTFOLIO = """
p.updated_at,
p.last_trade_at,
p.managed_by_daemon_id,
p.asset_id
p.asset_id,
COALESCE(p.tag_ids, '{}'::uuid[])
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
"""
_SELECT_TAG = """
SELECT
tag_id,
name,
created_by_user_id,
is_admin_tag,
is_active,
created_at
FROM tags
"""
_SELECT_ASSET = """
SELECT
asset_id,
@@ -235,6 +247,13 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS asset_id UUID")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS tag_ids UUID[] NOT NULL DEFAULT '{}'")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_tag_ids_gin
ON portfolios USING GIN (tag_ids)
"""
)
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
@@ -301,6 +320,19 @@ class PostgresPortfolioRepository:
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS tags (
tag_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_by_user_id TEXT NOT NULL,
is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(name)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daemons (
@@ -410,6 +442,7 @@ class PostgresPortfolioRepository:
last_trade_at=row[14] if len(row) > 14 else None,
managed_by_daemon_id=row[15] if len(row) > 15 else None,
asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None,
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
)
@staticmethod
@@ -432,6 +465,19 @@ class PostgresPortfolioRepository:
updated_at=row[6],
)
@staticmethod
def _row_to_tag(row: Any) -> Tag:
if row is None:
raise ValueError("태그를 찾을 수 없습니다")
return Tag(
tag_id=str(row[0]),
name=row[1],
created_by_user_id=row[2],
is_admin_tag=bool(row[3]),
is_active=bool(row[4]),
created_at=row[5] if len(row) > 5 else None,
)
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
if not portfolio_ids:
return {}
@@ -950,6 +996,7 @@ class PostgresPortfolioRepository:
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
@@ -993,6 +1040,9 @@ class PostgresPortfolioRepository:
"p.portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(exclude_tags)
if market is not None:
clauses.append("%s = ANY(a.markets)")
params.append(market)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY p.updated_at DESC, p.portfolio_id ASC"
@@ -1592,14 +1642,82 @@ class PostgresPortfolioRepository:
*,
include_disabled: bool = True,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
status_filter = None if include_disabled else "enabled"
return self.list_portfolios(
owner_user_id=owner_user_id,
status=status_filter,
managed_by_daemon_id=managed_by_daemon_id,
market=market,
)
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
VALUES (%s, %s, %s)
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(name, created_by_user_id, is_admin_tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}") from exc
row = cur.fetchone()
return self._row_to_tag(row)
def get_tag_by_id(self, tag_id: str) -> Tag | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = %s", (tag_id,))
row = cur.fetchone()
return self._row_to_tag(row) if row is not None else None
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
if not tag_ids:
return {}
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = ANY(%s)", (tag_ids,))
rows = cur.fetchall()
return {str(row[0]): self._row_to_tag(row) for row in rows}
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
self.ensure_schema()
query = _SELECT_TAG
if not include_inactive:
query += "WHERE is_active = TRUE "
query += "ORDER BY name ASC"
with self._connect().cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
return [self._row_to_tag(row) for row in rows]
def deactivate_tag(self, tag_id: str) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE tags SET is_active = FALSE
WHERE tag_id = %s
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(tag_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
return self._row_to_tag(row)
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
+33
View File
@@ -13,6 +13,11 @@ from pydantic import BaseModel, Field
# GET /v1/assets
# ---------------------------------------------------------------------------
class TagRefItem(BaseModel):
tag_id: str
archived: bool = False
class AssetItem(BaseModel):
asset_id: str
symbol: str
@@ -29,6 +34,7 @@ class AssetItem(BaseModel):
price: float | None = None
profit_buffer: float | None = None
tags: list[str] | None = None
tag_ids: list[TagRefItem] | None = None
owner_user_id: str | None = None
created_at: datetime | None = None
last_trade_at: datetime | None = None
@@ -92,3 +98,30 @@ class LedgerTradeResponse(BaseModel):
executed_at: datetime
recorded_at: datetime | None = None
status: Literal["new", "duplicate"] = "new"
# ---------------------------------------------------------------------------
# POST/PATCH /v1/assets (admin catalog management — REQ-010)
# ---------------------------------------------------------------------------
class AssetCatalogCreateRequest(BaseModel):
symbol: str = Field(..., min_length=1)
markets: list[str] = Field(default_factory=list)
display_name: str | None = None
status: str = "active"
class AssetCatalogUpdateRequest(BaseModel):
markets: list[str] | None = None
display_name: str | None = None
status: str | None = None
class AssetCatalogResponse(BaseModel):
asset_id: str
symbol: str
markets: list[str]
display_name: str | None = None
status: str
created_at: datetime | None = None
updated_at: datetime | None = None
+6
View File
@@ -207,6 +207,12 @@ class PortfolioService:
*,
owner_user_id: str = "system",
) -> PortfolioDetailResponse:
asset = self._repository.get_asset_by_id(payload.asset_id)
if asset is None:
raise ValueError(f"asset not found: {payload.asset_id}")
if asset.status != "active":
raise ValueError(f"asset is inactive: {payload.asset_id}")
active_strategy_id = payload.active_strategy_id
if active_strategy_id is None:
strategy_set = self._repository.get_or_create_strategy_set(