feat(req-002): implement portfolio tagging and tag filters

This commit is contained in:
2026-04-14 01:06:10 +09:00
parent 2dab17cbb1
commit a776ea3b1d
20 changed files with 1033 additions and 32 deletions
+78 -2
View File
@@ -1,6 +1,6 @@
"""Portfolio API routes."""
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
from app.core.auth import AuthContext, Permission, authorize_request
from app.core.config import Settings, get_settings
@@ -14,6 +14,8 @@ from app.schemas.portfolio import (
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
PortfolioTagMutationRequest,
PortfolioTagResponse,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
@@ -26,6 +28,8 @@ router = APIRouter(prefix="/portfolios", tags=["portfolios"])
def _raise_portfolio_error(exc: ValueError) -> None:
detail = str(exc)
if "이미 연결된 태그입니다" in detail:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
if "찾을 수 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
@@ -68,6 +72,9 @@ def list_portfolios(
request: Request,
broker: str | None = None,
status_filter: str | None = None,
include_tags: list[str] | None = Query(default=None, alias="includeTags"),
exclude_tags: list[str] | None = Query(default=None, alias="excludeTags"),
match_mode: str | None = Query(default=None, alias="matchMode"),
settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]:
auth_context = authorize_request(Permission.READ, request, settings)
@@ -76,7 +83,16 @@ def list_portfolios(
owner_user_id: str | None = None
if _ownership_required(settings) and auth_context.permission != Permission.ADMIN:
owner_user_id = _require_owner_context(auth_context)
return service.list_portfolios(broker=broker, status=status_filter, owner_user_id=owner_user_id)
return service.list_portfolios(
broker=broker,
status=status_filter,
owner_user_id=owner_user_id,
include_tags=include_tags,
exclude_tags=exclude_tags,
match_mode=match_mode,
)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@@ -329,3 +345,63 @@ def delete_comment(
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{portfolio_id}/tags", response_model=PortfolioTagResponse, status_code=status.HTTP_201_CREATED)
def add_tag(
portfolio_id: str,
payload: PortfolioTagMutationRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioTagResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.add_tag(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.get("/{portfolio_id}/tags", response_model=list[PortfolioTagResponse])
def list_tags(
portfolio_id: str,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> list[PortfolioTagResponse]:
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.list_tags(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.delete("/{portfolio_id}/tags", status_code=status.HTTP_204_NO_CONTENT)
def remove_tag(
portfolio_id: str,
request: Request,
payload: PortfolioTagMutationRequest = Body(...),
*,
settings: Settings = Depends(get_settings),
) -> Response:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
service.remove_tag(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+57 -2
View File
@@ -14,6 +14,7 @@ from app.schemas.portfolio import (
CommentUpdateRequest,
CreatePortfolioRequest,
InvestRequest,
PortfolioTagMutationRequest,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
@@ -24,7 +25,9 @@ from app.services.portfolio_service import PortfolioService
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
comment_app = typer.Typer(help="Portfolio comment history commands.")
tag_app = typer.Typer(help="Portfolio tag commands.")
portfolio_app.add_typer(comment_app, name="comment")
portfolio_app.add_typer(tag_app, name="tag")
def _parse_strategy_params(value: str) -> dict[str, Any]:
@@ -61,8 +64,20 @@ def list_portfolios(
ctx: typer.Context,
broker: str | None = typer.Option(None, help="Filter by broker."),
status: str | None = typer.Option(None, help="Filter by status."),
include_tags: list[str] | None = typer.Option(None, "--include-tag", help="Include tag filter. Repeatable."),
exclude_tags: list[str] | None = typer.Option(None, "--exclude-tag", help="Exclude tag filter. Repeatable."),
match_mode: str | None = typer.Option(None, "--match-mode", help="AND or OR for include tags."),
) -> None:
detail = _with_service(ctx, lambda service, app_context: service.list_portfolios(broker=broker, status=status))
detail = _with_service(
ctx,
lambda service, app_context: service.list_portfolios(
broker=broker,
status=status,
include_tags=include_tags,
exclude_tags=exclude_tags,
match_mode=match_mode,
),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No portfolios found.")
@@ -333,4 +348,44 @@ def comment_delete(
if ctx.obj.output_format == "json":
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": comment_id, "portfolio_id": portfolio_id})
else:
ctx.obj.console.print(f"Deleted comment: {comment_id}")
ctx.obj.console.print(f"Deleted comment: {comment_id}")
@tag_app.command("add")
def tag_add(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
tag: str = typer.Option(..., "--tag", help="Tag value."),
) -> None:
detail = _with_service(
ctx,
lambda service, app_context: service.add_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@tag_app.command("list")
def tag_list(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
) -> 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.")
@tag_app.command("remove")
def tag_remove(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
tag: str = typer.Option(..., "--tag", help="Tag value."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Remove tag {tag!r} from portfolio {portfolio_id}?")
_with_service(
ctx,
lambda service, app_context: service.remove_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
)
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()}")
+8
View File
@@ -73,6 +73,7 @@ class Portfolio:
status: str
name: str
symbol: str
tags: list[str] | None = None
owner_user_id: str | None = None
cash_balance: float = 0.0
asset_quantity: float = 0.0
@@ -111,6 +112,13 @@ class PortfolioComment:
updated_at: datetime | None = None
@dataclass
class PortfolioTag:
portfolio_id: str
tag: str
created_at: datetime | None = None
@runtime_checkable
class LiveAssetSource(Protocol):
def get_asset_quantity(self, symbol: str) -> float: ...
+10 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -126,8 +126,17 @@ class PortfolioRepository(Protocol):
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]: ...
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
def remove_tag(self, portfolio_id: str, *, tag: str) -> None: ...
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
def get_strategy_set(self, id: str) -> StrategySet | None: ...
+136 -4
View File
@@ -12,7 +12,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -329,6 +329,22 @@ class PostgresPortfolioRepository:
ON portfolio_comments (portfolio_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_tags (
portfolio_id TEXT NOT NULL,
tag_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (portfolio_id, tag_value)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_tags_tag_portfolio
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
@@ -407,6 +423,7 @@ class PostgresPortfolioRepository:
status=row[2] or "enabled",
name=row[3],
symbol=row[4],
tags=None,
cash_balance=float(row[5]),
asset_quantity=float(row[6]),
price=float(row[7]),
@@ -419,6 +436,35 @@ class PostgresPortfolioRepository:
last_trade_at=row[14] if len(row) > 14 else None,
)
@staticmethod
def _row_to_portfolio_tag(row: Any) -> PortfolioTag:
if row is None:
raise ValueError("태그를 찾을 수 없습니다")
return PortfolioTag(portfolio_id=row[0], tag=row[1], created_at=row[2] if len(row) > 2 else None)
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
if not portfolio_ids:
return {}
cur.execute(
"""
SELECT portfolio_id, tag_value
FROM portfolio_tags
WHERE portfolio_id = ANY(%s)
ORDER BY tag_value ASC
""",
(portfolio_ids,),
)
tags_by_portfolio: dict[str, list[str]] = {portfolio_id: [] for portfolio_id in portfolio_ids}
for portfolio_id, tag_value in cur.fetchall():
tags_by_portfolio.setdefault(str(portfolio_id), []).append(str(tag_value))
return tags_by_portfolio
def _attach_tags(self, cur: psycopg.Cursor, portfolios: list[Portfolio]) -> list[Portfolio]:
tags_by_portfolio = self._load_tags_for_portfolios(cur, [portfolio.portfolio_id for portfolio in portfolios])
for portfolio in portfolios:
portfolio.tags = tags_by_portfolio.get(portfolio.portfolio_id, [])
return portfolios
@staticmethod
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
return LedgerEntry(
@@ -780,7 +826,9 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
return self._row_to_portfolio(row)
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
return portfolio
def get_or_create(
self,
@@ -875,7 +923,9 @@ class PostgresPortfolioRepository:
),
)
row = cur.fetchone()
return self._row_to_portfolio(row)
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
return portfolio
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema()
@@ -925,6 +975,9 @@ class PostgresPortfolioRepository:
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]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
@@ -939,13 +992,92 @@ class PostgresPortfolioRepository:
if owner_user_id is not None:
clauses.append("owner_user_id = %s")
params.append(owner_user_id)
normalized_mode = (match_mode or "OR").upper()
if include_tags:
if normalized_mode == "AND":
clauses.append(
"""
portfolio_id IN (
SELECT portfolio_id
FROM portfolio_tags
WHERE tag_value = ANY(%s)
GROUP BY portfolio_id
HAVING COUNT(DISTINCT tag_value) = %s
)
"""
)
params.append(include_tags)
params.append(len(set(include_tags)))
else:
clauses.append(
"portfolio_id IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(include_tags)
if exclude_tags:
clauses.append(
"portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(exclude_tags)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY updated_at DESC, portfolio_id ASC"
with self._connect().cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
return [self._row_to_portfolio(row) for row in rows]
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
try:
cur.execute(
"""
INSERT INTO portfolio_tags (portfolio_id, tag_value)
VALUES (%s, %s)
RETURNING portfolio_id, tag_value, created_at
""",
(portfolio_id, tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 연결된 태그입니다: {tag}") from exc
row = cur.fetchone()
return self._row_to_portfolio_tag(row)
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
SELECT portfolio_id, tag_value, created_at
FROM portfolio_tags
WHERE portfolio_id = %s
ORDER BY tag_value ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_portfolio_tag(row) for row in rows]
def remove_tag(self, portfolio_id: str, *, tag: str) -> 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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"DELETE FROM portfolio_tags WHERE portfolio_id = %s AND tag_value = %s RETURNING portfolio_id",
(portfolio_id, tag),
)
if cur.fetchone() is None:
raise ValueError("태그를 찾을 수 없습니다")
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
self.ensure_schema()
+20 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class StrategySetResponse(BaseModel):
@@ -45,6 +45,7 @@ class PortfolioSummary(BaseModel):
status: str
name: str
symbol: str
tags: list[str] = Field(default_factory=list)
owner_user_id: str | None = None
cash_balance: float = 0.0
asset_quantity: float = 0.0
@@ -140,6 +141,24 @@ class CommentResponse(BaseModel):
updated_at: datetime | None = None
class PortfolioTagMutationRequest(BaseModel):
tag: str = Field(min_length=1)
@field_validator("tag")
@classmethod
def normalize_tag(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("tag는 비어 있을 수 없습니다")
return normalized
class PortfolioTagResponse(BaseModel):
portfolio_id: str
tag: str
created_at: datetime | None = None
class ErrorResponse(BaseModel):
detail: str
model_config = ConfigDict(extra="ignore")
+56 -2
View File
@@ -7,7 +7,7 @@ import json
from fastapi import Depends
from app.core.config import Settings, get_settings
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet
from app.domain.services import (
apply_buy,
apply_delete,
@@ -28,6 +28,8 @@ from app.schemas.portfolio import (
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
PortfolioTagMutationRequest,
PortfolioTagResponse,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
@@ -49,6 +51,7 @@ def _to_portfolio_summary(portfolio: Portfolio):
status=portfolio.status,
name=portfolio.name,
symbol=portfolio.symbol,
tags=list(portfolio.tags or []),
owner_user_id=portfolio.owner_user_id,
cash_balance=portfolio.cash_balance,
asset_quantity=portfolio.asset_quantity,
@@ -121,6 +124,31 @@ def _to_comment_response(comment: PortfolioComment) -> CommentResponse:
)
def _to_tag_response(tag: PortfolioTag) -> PortfolioTagResponse:
return PortfolioTagResponse(portfolio_id=tag.portfolio_id, tag=tag.tag, created_at=tag.created_at)
def _normalize_tag(value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("tag는 비어 있을 수 없습니다")
return normalized
def _normalize_tag_list(values: list[str] | None) -> list[str] | None:
if not values:
return None
normalized: list[str] = []
seen: set[str] = set()
for value in values:
item = _normalize_tag(value)
if item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized or None
def _to_detail_response(status_result) -> PortfolioDetailResponse:
return PortfolioDetailResponse(
portfolio=_to_portfolio_summary(status_result.portfolio),
@@ -144,8 +172,21 @@ class PortfolioService:
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[PortfolioListItem]:
portfolios = self._repository.list_portfolios(broker=broker, status=status, owner_user_id=owner_user_id)
normalized_mode = None if match_mode is None else match_mode.upper()
if normalized_mode is not None and normalized_mode not in {"AND", "OR"}:
raise ValueError("matchMode는 AND 또는 OR 여야 합니다")
portfolios = self._repository.list_portfolios(
broker=broker,
status=status,
owner_user_id=owner_user_id,
include_tags=_normalize_tag_list(include_tags),
exclude_tags=_normalize_tag_list(exclude_tags),
match_mode=normalized_mode,
)
return [
PortfolioListItem(
**_to_portfolio_summary(portfolio).model_dump(),
@@ -302,6 +343,19 @@ class PortfolioService:
self._repository.get(portfolio_id)
self._repository.delete_comment(portfolio_id, comment_id)
def add_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> PortfolioTagResponse:
self._repository.get(portfolio_id)
tag = self._repository.add_tag(portfolio_id, tag=_normalize_tag(payload.tag))
return _to_tag_response(tag)
def list_tags(self, portfolio_id: str) -> list[PortfolioTagResponse]:
self._repository.get(portfolio_id)
return [_to_tag_response(tag) for tag in self._repository.list_tags(portfolio_id)]
def remove_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> None:
self._repository.get(portfolio_id)
self._repository.remove_tag(portfolio_id, tag=_normalize_tag(payload.tag))
def get_portfolio_service(
repository: PortfolioRepository = Depends(get_portfolio_repository),