feat(req-002): implement portfolio tagging and tag filters
This commit is contained in:
+78
-2
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -334,3 +349,43 @@ def comment_delete(
|
||||
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}")
|
||||
|
||||
|
||||
@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()}")
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-002: 포트폴리오 태깅 및 태그 기반 검색 지원
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: Medium
|
||||
- Owner: Justin
|
||||
@@ -12,27 +12,30 @@
|
||||
## Requirement
|
||||
- 시스템은 각 포트폴리오에 대해 하나 이상의 태그를 추가하고 제거할 수 있어야 한다.
|
||||
- 시스템은 HTTP API와 CLI를 통해 동일한 포트폴리오 태깅 기능을 제공해야 한다.
|
||||
- 시스템은 태그 추가 시 입력 문자열의 앞뒤 공백을 제거한 값을 저장 및 비교 기준으로 사용해야 하며, 정규화 결과가 빈 문자열이면 validation 오류를 반환해야 한다.
|
||||
- 시스템은 태그 문자열의 대소문자, 내부 공백, 네임스페이스 구분자(`:`)를 정규화 과정에서 변경하지 않아야 하며, 앞뒤 공백 제거 외의 추가 정규화는 수행하지 않아야 한다.
|
||||
- 시스템은 둘 이상의 태그를 조건으로 포트폴리오를 검색할 수 있어야 하며, 검색 요청에서 `AND` 또는 `OR` 매칭 방식을 선택할 수 있어야 한다.
|
||||
- 시스템은 다운스트림 소비자가 태그 기반 목록 조회 시 사용할 수 있도록 포함 태그(`includeTags`)와 제외 태그(`excludeTags`)를 동시에 지원해야 한다.
|
||||
- 시스템은 HTTP API의 포트폴리오 목록 조회 인터페이스와 CLI의 동일 의미 인터페이스에서 포함 태그(`includeTags`), 제외 태그(`excludeTags`), 포함 태그 매칭 방식(`matchMode`: `AND` 또는 `OR`)을 동시에 지원해야 한다.
|
||||
- 시스템은 `matchMode`를 `includeTags` 집합에만 적용해야 하며, `excludeTags`는 요청된 태그 중 하나라도 일치하면 해당 포트폴리오를 결과에서 제외해야 한다.
|
||||
- 시스템은 태그 기반 조회에서 `grade:*`, `blacklist:*`와 같은 태그 네임스페이스를 그대로 보존하고 필터 조건으로 사용할 수 있어야 한다.
|
||||
- 시스템은 공백만으로 이루어지지 않은 태그 문자열을 추가 대상으로 받아들여야 한다.
|
||||
- 시스템은 동일 포트폴리오에 이미 연결된 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다.
|
||||
- 시스템은 동일 포트폴리오에 이미 연결된 태그와 앞뒤 공백 제거 후 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다.
|
||||
- 시스템은 태그 추가·삭제·검색 요청에 대해 기존 포트폴리오 접근 권한 정책을 적용해야 하며, 인증 실패 또는 권한 부족 시 기존 인증 오류 계약에 따라 요청을 거부해야 한다.
|
||||
- 시스템은 존재하지 않는 `portfolio_id`를 대상으로 한 태그 추가·삭제 요청에 대해 Not Found 오류를 반환해야 한다.
|
||||
- 시스템은 태그 검색 결과에서 각 포트폴리오를 중복 없이 한 번만 반환해야 한다.
|
||||
- 시스템은 태그 문자열 길이 또는 포트폴리오당 태그 개수에 대해 애플리케이션 정의 최대 제한만을 이유로 요청을 거부하지 않아야 한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 해당 포트폴리오에 태그를 연결하고 후속 조회에서 그 태그가 포함된 상태를 반환해야 한다.
|
||||
1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 입력값의 앞뒤 공백을 제거한 태그를 해당 포트폴리오에 연결하고 후속 조회에서 그 정규화된 태그가 포함된 상태를 반환해야 한다.
|
||||
2. Given 특정 포트폴리오에 이미 연결된 태그가 있을 때, When 사용자가 API 또는 CLI로 해당 태그를 제거하면, Then 시스템은 성공 응답을 반환하고 후속 조회 및 태그 검색 결과에서 그 연결을 노출하지 않아야 한다.
|
||||
3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `AND` 모드를 지정해 검색하면, Then 시스템은 요청한 모든 태그를 포함한 포트폴리오만 반환해야 한다.
|
||||
4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `OR` 모드를 지정해 검색하면, Then 시스템은 요청한 태그 중 하나 이상을 포함한 포트폴리오를 반환해야 한다.
|
||||
5. Given 동일 포트폴리오에 이미 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다.
|
||||
3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=AND`를 지정해 검색하면, Then 시스템은 요청한 모든 포함 태그를 가진 포트폴리오만 반환해야 한다.
|
||||
4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=OR`를 지정해 검색하면, Then 시스템은 요청한 포함 태그 중 하나 이상을 가진 포트폴리오를 반환해야 한다.
|
||||
5. Given 동일 포트폴리오에 이미 앞뒤 공백 제거 후 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다.
|
||||
6. Given 인증되지 않았거나 해당 포트폴리오에 접근 권한이 없는 사용자가 API 또는 CLI로 태그 추가·삭제·검색을 시도할 때, When 요청을 전송하면, Then 시스템은 기존 인증/인가 계약에 따라 `401` 또는 `403` 응답을 반환하고 태그 상태를 변경하지 않아야 한다.
|
||||
7. Given 존재하지 않는 `portfolio_id`가 요청에 포함될 때, When 사용자가 API 또는 CLI로 태그 추가 또는 삭제를 시도하면, Then 시스템은 `404` 응답 또는 이에 대응하는 CLI 오류를 반환하고 어떤 태그 연결도 생성·변경하지 않아야 한다.
|
||||
8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다.
|
||||
8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 앞뒤 공백 제거 후 빈 문자열이 아닌 한 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다.
|
||||
9. Given `OR` 모드의 다중 태그 검색에서 하나의 포트폴리오가 두 개 이상의 요청 태그와 동시에 일치할 때, When 사용자가 검색을 수행하면, Then 시스템은 해당 포트폴리오를 결과에 한 번만 반환해야 한다.
|
||||
10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 포함 조건을 만족하고 제외 조건을 만족하지 않는 항목만 반환해야 한다.
|
||||
10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]`, `matchMode=OR` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 `grade:A` 또는 `grade:B` 중 하나 이상을 포함하고 `blacklist:trade`를 하나라도 포함하지 않는 항목만 반환해야 한다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs: `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
|
||||
@@ -43,10 +46,74 @@
|
||||
- 본 requirement의 검색 범위는 다중 태그 검색을 포함하며, 호출자는 `AND` 또는 `OR` 모드를 선택할 수 있다.
|
||||
- 태그 최대 길이와 포트폴리오당 최대 태그 개수에 대한 애플리케이션 정의 상한은 두지 않는다.
|
||||
- 동일 포트폴리오에 같은 태그 문자열을 다시 추가하는 요청은 `409 Conflict` 또는 이에 대응하는 CLI 오류로 처리한다.
|
||||
- 태그 정규화는 앞뒤 공백 제거만 수행하며, 대소문자와 내부 문자열은 보존한다.
|
||||
- HTTP API와 CLI는 `includeTags`, `excludeTags`, `matchMode`에 대해 동일 의미의 검색 계약을 제공한다.
|
||||
- 특정 소비 서비스의 입력 고정/스냅샷 책임은 소비 서비스 측 requirement에서 관리하며, portfolio-hub는 태그 등록/검색 계약만 제공한다.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260414-02
|
||||
- Date: 2026-04-14
|
||||
- Author: GitHub Copilot
|
||||
|
||||
#### Change Summary
|
||||
- REQ-002를 Proposed에서 Approved로 전환했다.
|
||||
|
||||
#### Intent
|
||||
- 태그 정규화 규칙과 태그 검색 입력 계약이 승인 기준을 충족했음을 반영하기 위함이다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- Status를 Approved로 전환
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md`
|
||||
- `docs/SRS/index.md`
|
||||
- `docs/SRS/traceability-matrix.md`
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 technical spec 동기화 시 REQ-002의 정규화 및 검색 계약이 관련 technical 문서에 동일하게 반영되는지 확인한다.
|
||||
|
||||
### CHG-20260414-01
|
||||
- Date: 2026-04-14
|
||||
- Author: GitHub Copilot
|
||||
|
||||
#### Change Summary
|
||||
- REQ-002의 태그 정규화 규칙과 태그 검색 입력 계약을 구체화해 승인 blocker를 해소할 수 있도록 보강했다.
|
||||
|
||||
#### Intent
|
||||
- 태그 중복 판단과 include/exclude 검색 동작이 구현마다 다르게 해석되지 않도록 인터페이스 계약을 고정하기 위함이다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 앞뒤 공백 제거 기반 태그 정규화 규칙
|
||||
- `includeTags`, `excludeTags`, `matchMode`의 결합 규칙
|
||||
- Updated:
|
||||
- AC1, AC3, AC4, AC5, AC8, AC10의 입력/비교 기준 명확화
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md`
|
||||
- `docs/SRS/index.md`
|
||||
- Modules:
|
||||
- tag input normalization 및 검색 query parser 구현 경로
|
||||
- Tests:
|
||||
- 태그 정규화, 중복 추가 충돌, include/exclude 조합 검색 검증 시나리오
|
||||
|
||||
#### Validation Plan
|
||||
- 문서 리뷰 시 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 조합 규칙이 각각 독립 테스트로 매핑 가능한지 확인한다.
|
||||
|
||||
### CHG-20260413-05
|
||||
- Date: 2026-04-13
|
||||
- Author: GitHub Copilot
|
||||
|
||||
+3
-3
@@ -22,16 +22,16 @@
|
||||
| REQ ID | Title | Type | Status | Priority | Owner | File |
|
||||
|---|---|---|---|---|---|---|
|
||||
| REQ-001 | 포트폴리오 댓글형 메모 이력 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` |
|
||||
| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Proposed | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` |
|
||||
| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` |
|
||||
| REQ-003 | 포트폴리오 필수 필드 및 거래소/시장 데이터 정의 | Data | Proposed | Medium | Justin | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` |
|
||||
| REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` |
|
||||
|
||||
## Recent Change Log Summary
|
||||
- 2026-04-14 — `REQ-002`를 Approved로 전환했다.
|
||||
- 2026-04-14 — `REQ-002`에 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 검색 계약을 추가해 승인 blocker를 줄였다.
|
||||
- 2026-04-14 — `REQ-004`에 사용자 귀속 봇 principal(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙(AC6, AC7)을 추가했다.
|
||||
- 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다.
|
||||
- 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다.
|
||||
- 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다.
|
||||
- 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다.
|
||||
|
||||
## Status Transition Evidence
|
||||
- `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| REQ ID | AC Mapping | Requirement File | Implementation Issue | PR | Test Evidence | Requirement Status | Delivery Status | Last Updated |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 |
|
||||
| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
|
||||
| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9, AC10 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | `tests/test_portfolio_tags.py`, `tests/test_cli.py`, local `pytest` pass (65 passed, 2026-04-14) | Approved | Verified | 2026-04-14 |
|
||||
| REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
|
||||
| REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 |
|
||||
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
- `POST /portfolios/{portfolio_id}/transfer`
|
||||
- `POST /portfolios/{portfolio_id}/reset`
|
||||
- `DELETE /portfolios/{portfolio_id}`
|
||||
- `POST /portfolios/{portfolio_id}/tags`
|
||||
- `GET /portfolios/{portfolio_id}/tags`
|
||||
- `DELETE /portfolios/{portfolio_id}/tags`
|
||||
|
||||
REQ-002 정렬 메모:
|
||||
|
||||
- 포트폴리오 목록 조회는 반복 query parameter `includeTags`, `excludeTags`, `matchMode`를 지원한다.
|
||||
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 구현한다.
|
||||
- CLI는 `portfolio tag add|list|remove` 및 `portfolio list --include-tag/--exclude-tag/--match-mode` 계약을 제공한다.
|
||||
|
||||
### 포트폴리오 댓글 (`REQ-001`)
|
||||
- `POST /portfolios/{portfolio_id}/comments`
|
||||
@@ -106,12 +115,17 @@
|
||||
- Query:
|
||||
- `broker: string | null`
|
||||
- `status_filter: string | null`
|
||||
- `includeTags: string[] | null`
|
||||
- `excludeTags: string[] | null`
|
||||
- `matchMode: "AND" | "OR" | null` (`includeTags`에만 적용)
|
||||
- 응답 `200`: `PortfolioListItem[]`
|
||||
- 오류:
|
||||
- `400`: `matchMode`가 `AND` 또는 `OR`가 아님
|
||||
|
||||
예시:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
|
||||
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled&includeTags=grade:A&excludeTags=blacklist:trade&matchMode=OR" \
|
||||
-H "Authorization: Bearer <READ_TOKEN>"
|
||||
```
|
||||
|
||||
@@ -123,6 +137,7 @@ curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled
|
||||
"status": "enabled",
|
||||
"name": "Bitcoin",
|
||||
"symbol": "KRW-BTC",
|
||||
"tags": ["grade:A"],
|
||||
"cash_balance": 1000000.0,
|
||||
"asset_quantity": 0.1,
|
||||
"price": 100000000.0,
|
||||
@@ -157,6 +172,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
|
||||
"status": "enabled",
|
||||
"name": "Bitcoin",
|
||||
"symbol": "KRW-BTC",
|
||||
"tags": ["grade:A"],
|
||||
"owner_user_id": "user-1",
|
||||
"cash_balance": 1000000.0,
|
||||
"asset_quantity": 0.1,
|
||||
@@ -246,6 +262,16 @@ curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \
|
||||
- `POST /portfolios/{portfolio_id}/transfer`: `TransferRequest` -> `PortfolioDetailResponse`
|
||||
- `POST /portfolios/{portfolio_id}/reset`: `ResetPortfolioRequest` -> `PortfolioDetailResponse`
|
||||
- `DELETE /portfolios/{portfolio_id}`: admin only, query `keep_ledger: bool = false`, 응답 `204`
|
||||
- `POST /portfolios/{portfolio_id}/tags`: `PortfolioTagMutationRequest` -> `PortfolioTagResponse`
|
||||
- `GET /portfolios/{portfolio_id}/tags`: `PortfolioTagResponse[]`
|
||||
- `DELETE /portfolios/{portfolio_id}/tags`: body `PortfolioTagMutationRequest`, 응답 `204`
|
||||
|
||||
태그 엔드포인트 추가 규칙:
|
||||
|
||||
- `POST`/`DELETE`는 `trade-write`, `GET`은 `read`
|
||||
- 비관리자 요청은 기존 ownership 검사를 동일하게 적용
|
||||
- 입력 `tag`는 앞뒤 공백을 제거한 값을 저장/비교 기준으로 사용
|
||||
- 정규화 후 빈 문자열은 `422`, 중복 태그 추가는 `409`, 포트폴리오 또는 태그 미존재는 `404`
|
||||
|
||||
대표 요청:
|
||||
|
||||
@@ -277,6 +303,19 @@ curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \
|
||||
|
||||
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
curl -X POST "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag": " grade:A "}'
|
||||
|
||||
curl -X GET "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <READ_TOKEN>"
|
||||
|
||||
curl -X DELETE "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag": "grade:A"}'
|
||||
```
|
||||
|
||||
#### 포트폴리오 댓글 (`REQ-001`)
|
||||
@@ -703,7 +742,7 @@ curl -X POST "http://localhost:8000/users" \
|
||||
|
||||
## 문서 동기화 규칙
|
||||
- `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다.
|
||||
- `REQ-002` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다.
|
||||
- `REQ-002` 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다.
|
||||
|
||||
## Open Implementation Points
|
||||
- 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| `settings-and-env-loading.md` | `.env`/환경변수 로딩, 필수 키, CORS/운영 설정 규칙 | N/A |
|
||||
| `architecture-overview.md` | 시스템 개요, 모듈 책임, 런타임 흐름, 테스트/호환성 기준 | `REQ-003` |
|
||||
| `api-contracts.md` | HTTP API 상세 계약, request/response 모델, 대표 payload 예시 | `REQ-001`, `REQ-002`, `REQ-003` |
|
||||
| `portfolio-tagging-and-search-design.md` | 태그 정규화, API/CLI 검색 파라미터, 모듈 책임, 구현 단계 설계 | `REQ-002` |
|
||||
| `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` |
|
||||
| `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A |
|
||||
| `portfolio-ownership-and-auth-design.md` | 포트폴리오 소유주(`owner_user_id`) 도입, 소유권 접근 제어, 검증 시나리오 설계 | `REQ-004` |
|
||||
@@ -30,7 +31,7 @@
|
||||
| REQ ID | Technical Docs |
|
||||
| --- | --- |
|
||||
| `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-002` | `api-contracts.md` |
|
||||
| `REQ-002` | `api-contracts.md`, `portfolio-tagging-and-search-design.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-004` | `auth-and-token-lifecycle.md`, `api-contracts.md`, `persistence-and-migrations.md`, `portfolio-ownership-and-auth-design.md` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Persistence and Migrations
|
||||
|
||||
- Related REQ: `REQ-001`, `REQ-003`, `REQ-004`
|
||||
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004`
|
||||
|
||||
## 목표
|
||||
DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다.
|
||||
@@ -257,6 +257,48 @@ Conditional migration behavior:
|
||||
- application layer cascade:
|
||||
- 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다.
|
||||
|
||||
## REQ-002 Tagging Storage Design
|
||||
|
||||
목표:
|
||||
|
||||
- 포트폴리오별 태그 연결을 중복 없이 저장한다.
|
||||
- 앞뒤 공백 제거 기반 정규화와 검색용 include/exclude 조건을 DB 계층에서 일관되게 지원한다.
|
||||
|
||||
구현 저장 모델:
|
||||
|
||||
- 신규 테이블 `portfolio_tags`
|
||||
- 복합 PK(`portfolio_id`, `tag_value`) 사용
|
||||
- `portfolio_id`: 대상 포트폴리오
|
||||
- `tag_value`: 앞뒤 공백 제거 후 저장되는 태그 값
|
||||
- `created_at`: 연결 생성 시각
|
||||
- 동일 포트폴리오 내 중복 태그는 PK 자체로 방지한다.
|
||||
|
||||
정규화 책임:
|
||||
|
||||
- API/CLI 입력 계층에서 앞뒤 공백 제거를 수행한다.
|
||||
- repository는 이미 정규화된 `tag_value`를 기준으로 insert/delete/query를 수행한다.
|
||||
- DB는 `UNIQUE (portfolio_id, tag_value)`로 중복 insert를 방지하고 application은 이를 `409 Conflict`로 매핑한다.
|
||||
|
||||
검색 전략:
|
||||
|
||||
- `includeTags`와 `excludeTags`는 모두 정규화된 태그 값 기준으로 비교한다.
|
||||
- `matchMode=AND`는 `GROUP BY portfolio_id` + `HAVING COUNT(DISTINCT tag_value) = <includeTags 개수>` 전략을 기본안으로 둔다.
|
||||
- `matchMode=OR`는 `WHERE tag_value IN (...)` + distinct portfolio projection을 기본안으로 둔다.
|
||||
- `excludeTags`는 anti-join 또는 `NOT EXISTS` 전략을 사용한다.
|
||||
|
||||
권장 인덱스:
|
||||
|
||||
- `UNIQUE (portfolio_id, tag_value)`
|
||||
- `INDEX (tag_value, portfolio_id)`
|
||||
- owner-scoped 목록 필터와 결합될 수 있으므로 `portfolios.owner_user_id` 인덱스와 함께 실행 계획을 검토한다.
|
||||
|
||||
적용 마이그레이션 단계:
|
||||
|
||||
1. `20260414_0012_portfolio_tags.py`에서 `portfolio_tags` 테이블 생성
|
||||
2. `fk_portfolio_tags_portfolio` FK와 `idx_portfolio_tags_tag_portfolio` 인덱스 생성
|
||||
3. API/CLI/repository가 동일 revision 기준으로 태그 mutation 및 검색을 사용
|
||||
4. 필요 시 exact-match 중심 추가 인덱스를 검토
|
||||
|
||||
## Validation Notes
|
||||
- 마이그레이션은 빈 DB와 기존 DB 모두에서 연속 적용 가능해야 한다.
|
||||
- 파괴적 변경(drop/rename)은 백필 또는 호환 단계 계획 없이 직접 반영하지 않는다.
|
||||
@@ -276,7 +318,9 @@ Conditional migration behavior:
|
||||
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` and backfill from `symbol` |
|
||||
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo history |
|
||||
| 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK |
|
||||
| 20260414_0011 | migrations/versions/20260414_0011_bot_principal_owner_user_id.py | Restore `bot_principals.owner_user_id`, backfill owner, create owner index, and add owner FK |
|
||||
| 20260414_0012 | migrations/versions/20260414_0012_portfolio_tags.py | Add `portfolio_tags`, portfolio FK, and tag lookup index for REQ-002 |
|
||||
|
||||
## Open Implementation Points
|
||||
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
|
||||
- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다.
|
||||
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Portfolio Tagging and Search Design
|
||||
|
||||
- Related REQ: `REQ-002`
|
||||
|
||||
## 목표
|
||||
- 승인된 `REQ-002`를 구현 가능한 내부 설계로 정리한다.
|
||||
- 태그 정규화, 검색 파라미터 의미, 모듈 책임, 저장소/마이그레이션 방향을 한 문서에서 추적한다.
|
||||
|
||||
## 구현 범위
|
||||
- 포트폴리오별 태그 추가/삭제 흐름
|
||||
- HTTP API와 CLI의 공통 태그 검색 파라미터 의미
|
||||
- repository query 전략과 DB 인덱스 후보
|
||||
|
||||
제외:
|
||||
|
||||
- 태그 추천, 자동 분류, 네임스페이스 사전 관리
|
||||
- 부분 일치/전방 일치/full-text 검색
|
||||
|
||||
## 정규화 정책
|
||||
- 입력 태그는 API/CLI 계층에서 앞뒤 공백을 제거한다.
|
||||
- 정규화 후 빈 문자열이면 validation 오류로 처리한다.
|
||||
- 대소문자, 내부 공백, `:` 네임스페이스는 변경하지 않는다.
|
||||
- 중복 판단은 정규화된 값 기준으로 수행한다.
|
||||
|
||||
예시:
|
||||
|
||||
- `" grade:A "` -> `"grade:A"`
|
||||
- `"Grade:A"`와 `"grade:A"`는 서로 다른 태그로 취급한다.
|
||||
- `"blacklist: trade"`의 내부 공백은 보존한다.
|
||||
|
||||
## 모듈 책임
|
||||
|
||||
API:
|
||||
|
||||
- 대상 파일: `app/api/portfolios.py`
|
||||
- 책임:
|
||||
- `POST/GET/DELETE /portfolios/{portfolio_id}/tags` 제공
|
||||
- `GET /portfolios`의 반복 query 파라미터 `includeTags`, `excludeTags`, `matchMode` 수신
|
||||
- 기존 ownership/auth 정책 재사용
|
||||
|
||||
CLI:
|
||||
|
||||
- 대상 파일: `app/cli/commands/portfolio.py`
|
||||
- 책임:
|
||||
- `portfolio tag add|list|remove` 명령 노출
|
||||
- `portfolio list --include-tag --exclude-tag --match-mode` 옵션 제공
|
||||
|
||||
Schema:
|
||||
|
||||
- 대상 파일: `app/schemas/portfolio.py`
|
||||
- 책임:
|
||||
- tag mutation request/response 모델 정의
|
||||
- list query 파라미터 contract 정리
|
||||
|
||||
Service:
|
||||
|
||||
- 대상 파일: `app/services/portfolio_service.py`
|
||||
- 책임:
|
||||
- 정규화된 태그 입력 검증
|
||||
- repository 호출 orchestration
|
||||
- duplicate/not-found/auth 오류를 API/CLI가 소비할 수 있는 에러로 유지
|
||||
|
||||
Repository:
|
||||
|
||||
- 대상 파일: `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`
|
||||
- 책임:
|
||||
- 태그 연결 추가/삭제
|
||||
- include/exclude + matchMode query 제공
|
||||
- duplicate insert를 명시적 오류로 변환
|
||||
|
||||
## 검색 파라미터 의미
|
||||
|
||||
- `includeTags`
|
||||
- 포함해야 하는 정규화 태그 목록
|
||||
- `excludeTags`
|
||||
- 하나라도 일치하면 제외할 정규화 태그 목록
|
||||
- `matchMode`
|
||||
- `includeTags`에만 적용
|
||||
- `AND`: 모든 include tag 필요
|
||||
- `OR`: 하나 이상의 include tag 필요
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. 입력 태그 정규화
|
||||
2. ownership/auth 필터 적용
|
||||
3. include 조건 계산
|
||||
4. exclude 조건 제거
|
||||
5. 중복 portfolio 제거
|
||||
6. 기존 recency ordering과 충돌 여부 확인
|
||||
|
||||
## Validation Notes
|
||||
- duplicate tag add는 `UNIQUE (portfolio_id, tag_value)` 또는 동등한 repository 검증으로 `409`에 매핑해야 한다.
|
||||
- 존재하지 않는 포트폴리오에 대한 add/remove는 `404`로 매핑해야 한다.
|
||||
- 검색 결과는 `OR` 조건에서 중복 없이 한 번만 반환해야 한다.
|
||||
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
|
||||
|
||||
## 파일 위치 및 구현 후보
|
||||
- API route extension: `app/api/portfolios.py`
|
||||
- CLI extension: `app/cli/commands/portfolio.py`
|
||||
- schema extension: `app/schemas/portfolio.py`
|
||||
- service orchestration: `app/services/portfolio_service.py`
|
||||
- repository contract: `app/repositories/portfolio_repository.py`
|
||||
- Postgres query implementation: `app/repositories/postgres.py`
|
||||
- migration: `migrations/versions/20260414_0012_portfolio_tags.py`
|
||||
|
||||
## 구현 결정
|
||||
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 고정했다.
|
||||
- `GET /portfolios` 태그 필터는 반복 query parameter 방식으로 구현했다.
|
||||
- CLI 다중 태그 입력은 반복 옵션 `--include-tag`, `--exclude-tag`로 구현했다.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""add portfolio tags table
|
||||
|
||||
Revision ID: 20260414_0012
|
||||
Revises: 20260414_0011
|
||||
Create Date: 2026-04-14 16:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260414_0012"
|
||||
down_revision = "20260414_0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
op.create_table(
|
||||
"portfolio_tags",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("tag_value", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
|
||||
op.create_foreign_key(
|
||||
"fk_portfolio_tags_portfolio",
|
||||
"portfolio_tags",
|
||||
"portfolios",
|
||||
["portfolio_id"],
|
||||
["portfolio_id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if not _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
|
||||
op.create_index(
|
||||
"idx_portfolio_tags_tag_portfolio",
|
||||
"portfolio_tags",
|
||||
["tag_value", "portfolio_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "portfolio_tags"):
|
||||
if _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
|
||||
op.drop_constraint("fk_portfolio_tags_portfolio", "portfolio_tags", type_="foreignkey")
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
|
||||
op.drop_index("idx_portfolio_tags_tag_portfolio", table_name="portfolio_tags")
|
||||
|
||||
op.drop_table("portfolio_tags")
|
||||
+79
-1
@@ -9,7 +9,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet, User
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -21,6 +21,7 @@ class FakeRepo:
|
||||
self._users: dict[str, User] = {}
|
||||
self._user_seq = 1
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -179,6 +180,9 @@ class FakeRepo:
|
||||
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]:
|
||||
if self._portfolio is None:
|
||||
return []
|
||||
@@ -188,8 +192,46 @@ class FakeRepo:
|
||||
return []
|
||||
if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id:
|
||||
return []
|
||||
tags = set(self._portfolio.tags or [])
|
||||
if include_tags:
|
||||
required = set(include_tags)
|
||||
mode = (match_mode or "OR").upper()
|
||||
if mode == "AND" and not required.issubset(tags):
|
||||
return []
|
||||
if mode != "AND" and tags.isdisjoint(required):
|
||||
return []
|
||||
if exclude_tags and tags.intersection(exclude_tags):
|
||||
return []
|
||||
return [self._portfolio]
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
|
||||
portfolio = self.get(portfolio_id)
|
||||
normalized = tag.strip()
|
||||
key = f"{portfolio_id}:{normalized}"
|
||||
if key in self._tags:
|
||||
raise ValueError(f"이미 연결된 태그입니다: {normalized}")
|
||||
created = datetime.now(timezone.utc)
|
||||
item = PortfolioTag(portfolio_id=portfolio_id, tag=normalized, created_at=created)
|
||||
self._tags[key] = item
|
||||
portfolio.tags = sorted([*list(portfolio.tags or []), normalized])
|
||||
return item
|
||||
|
||||
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
|
||||
self.get(portfolio_id)
|
||||
return sorted(
|
||||
[tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id],
|
||||
key=lambda item: item.tag,
|
||||
)
|
||||
|
||||
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
|
||||
portfolio = self.get(portfolio_id)
|
||||
normalized = tag.strip()
|
||||
key = f"{portfolio_id}:{normalized}"
|
||||
if key not in self._tags:
|
||||
raise ValueError("태그를 찾을 수 없습니다")
|
||||
del self._tags[key]
|
||||
portfolio.tags = [item for item in (portfolio.tags or []) if item != normalized]
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3")
|
||||
self._strategy_sets[self.strategy_set.id] = self.strategy_set
|
||||
@@ -691,3 +733,39 @@ def test_cli_comment_list_nonexistent_portfolio() -> None:
|
||||
result = runner.invoke(runner.app, ["portfolio", "comment", "list", "nonexistent:id"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_cli_tag_add_and_list() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
add_result = runner.invoke(runner.app, ["portfolio", "tag", "add", "005930:tv", "--tag", " grade:A "])
|
||||
list_result = runner.invoke(runner.app, ["--format", "json", "portfolio", "tag", "list", "005930:tv"])
|
||||
|
||||
assert add_result.exit_code == 0
|
||||
assert "grade:A" in add_result.stdout
|
||||
assert list_result.exit_code == 0
|
||||
payload = json.loads(list_result.stdout)
|
||||
assert payload[0]["tag"] == "grade:A"
|
||||
|
||||
|
||||
def test_cli_list_supports_tag_filters() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.add_tag("005930:tv", tag="grade:A")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "005930:tv" in result.stdout
|
||||
|
||||
|
||||
def test_cli_tag_remove_flow() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.add_tag("005930:tv", tag="grade:A")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "005930:tv", "--tag", "grade:A", "--force"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Removed tag: grade:A" in result.stdout
|
||||
|
||||
@@ -63,6 +63,9 @@ class FakeRepo:
|
||||
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]:
|
||||
if self._portfolio is None:
|
||||
return []
|
||||
@@ -70,6 +73,15 @@ class FakeRepo:
|
||||
return []
|
||||
return [self._portfolio]
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def list_tags(self, portfolio_id: str):
|
||||
return []
|
||||
|
||||
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self.strategy_set if id == self.strategy_set.id else None
|
||||
|
||||
|
||||
@@ -50,11 +50,20 @@ class FakeRepo:
|
||||
setattr(portfolio, key, value)
|
||||
return portfolio
|
||||
|
||||
def list_portfolios(self, *, broker=None, status=None, owner_user_id=None):
|
||||
def list_portfolios(self, *, broker=None, status=None, owner_user_id=None, include_tags=None, exclude_tags=None, match_mode=None):
|
||||
if owner_user_id is not None and self._portfolio and self._portfolio.owner_user_id != owner_user_id:
|
||||
return []
|
||||
return [self._portfolio] if self._portfolio else []
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def list_tags(self, portfolio_id: str):
|
||||
return []
|
||||
|
||||
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self.strategy_set
|
||||
|
||||
|
||||
@@ -35,6 +35,29 @@ class FakeRepo:
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
return []
|
||||
|
||||
def list_portfolios(
|
||||
self,
|
||||
*,
|
||||
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,
|
||||
):
|
||||
if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id:
|
||||
return []
|
||||
return [self._portfolio]
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def list_tags(self, portfolio_id: str):
|
||||
return []
|
||||
|
||||
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_strategy_set(self, id: str):
|
||||
if id == self._strategy.id:
|
||||
return self._strategy
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.api.portfolios as portfolios_api
|
||||
from app.core.config import get_settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioTag, StrategySet
|
||||
from app.main import app
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, portfolio: Portfolio | None = None) -> None:
|
||||
self._portfolio = portfolio
|
||||
self._strategy = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:demo")
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def list_users(self):
|
||||
return []
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
return self._portfolio
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
return []
|
||||
|
||||
def get_strategy_set(self, id: str):
|
||||
return self._strategy if id == self._strategy.id else None
|
||||
|
||||
def list_portfolios(
|
||||
self,
|
||||
*,
|
||||
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]:
|
||||
if self._portfolio is None:
|
||||
return []
|
||||
if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id:
|
||||
return []
|
||||
tags = set(self._portfolio.tags or [])
|
||||
if include_tags:
|
||||
mode = (match_mode or "OR").upper()
|
||||
required = set(include_tags)
|
||||
if mode == "AND" and not required.issubset(tags):
|
||||
return []
|
||||
if mode != "AND" and tags.isdisjoint(required):
|
||||
return []
|
||||
if exclude_tags and tags.intersection(exclude_tags):
|
||||
return []
|
||||
return [self._portfolio]
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
|
||||
portfolio = self.get(portfolio_id)
|
||||
key = f"{portfolio_id}:{tag}"
|
||||
if key in self._tags:
|
||||
raise ValueError(f"이미 연결된 태그입니다: {tag}")
|
||||
item = PortfolioTag(portfolio_id=portfolio_id, tag=tag, created_at=datetime.now(timezone.utc))
|
||||
self._tags[key] = item
|
||||
portfolio.tags = sorted([*list(portfolio.tags or []), tag])
|
||||
return item
|
||||
|
||||
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
|
||||
self.get(portfolio_id)
|
||||
return sorted([tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id], key=lambda item: item.tag)
|
||||
|
||||
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
|
||||
portfolio = self.get(portfolio_id)
|
||||
key = f"{portfolio_id}:{tag}"
|
||||
if key not in self._tags:
|
||||
raise ValueError("태그를 찾을 수 없습니다")
|
||||
del self._tags[key]
|
||||
portfolio.tags = [item for item in (portfolio.tags or []) if item != tag]
|
||||
|
||||
|
||||
def _portfolio() -> Portfolio:
|
||||
return Portfolio(
|
||||
portfolio_id="p1",
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="Samsung Electronics",
|
||||
symbol="005930",
|
||||
tags=[],
|
||||
owner_user_id="owner-1",
|
||||
cash_balance=1_000_000.0,
|
||||
asset_quantity=1.0,
|
||||
price=70_000.0,
|
||||
profit_buffer=0.0,
|
||||
active_strategy_id="set-1",
|
||||
avabile_strategies=["set-1"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _setup_client(monkeypatch, repo: FakeRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "write-token")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
|
||||
get_settings.cache_clear()
|
||||
app.dependency_overrides.clear()
|
||||
monkeypatch.setattr(portfolios_api, "get_portfolio_repository_from_settings", lambda settings: repo)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_add_tag_normalizes_and_lists(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
add_response = client.post(
|
||||
"/portfolios/p1/tags",
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
json={"tag": " grade:A "},
|
||||
)
|
||||
list_response = client.get("/portfolios/p1/tags", headers={"Authorization": "Bearer read-token"})
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert add_response.status_code == 201
|
||||
assert add_response.json()["tag"] == "grade:A"
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[0]["tag"] == "grade:A"
|
||||
|
||||
|
||||
def test_duplicate_tag_returns_409(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.add_tag("p1", tag="grade:A")
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/portfolios/p1/tags",
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
json={"tag": " grade:A "},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_portfolio_list_filters_by_include_and_exclude_tags(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.add_tag("p1", tag="grade:A")
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
include_response = client.get(
|
||||
"/portfolios",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
params=[("includeTags", "grade:A"), ("matchMode", "AND")],
|
||||
)
|
||||
exclude_response = client.get(
|
||||
"/portfolios",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
params=[("excludeTags", "grade:A")],
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert include_response.status_code == 200
|
||||
assert include_response.json()[0]["tags"] == ["grade:A"]
|
||||
assert exclude_response.status_code == 200
|
||||
assert exclude_response.json() == []
|
||||
|
||||
|
||||
def test_remove_tag_returns_204(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.add_tag("p1", tag="grade:A")
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
"/portfolios/p1/tags",
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
json={"tag": "grade:A"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 204
|
||||
assert repo._portfolio is not None
|
||||
assert repo._portfolio.tags == []
|
||||
Reference in New Issue
Block a user