feat(req-002): implement portfolio tagging and tag filters
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user