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
+12
View File
@@ -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
+10 -1
View File
@@ -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
+23
View File
@@ -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
+187
View File
@@ -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 == []