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
+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()