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
+79 -1
View File
@@ -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