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.")
|
||||
|
||||
|
||||
@@ -333,4 +348,44 @@ def comment_delete(
|
||||
if ctx.obj.output_format == "json":
|
||||
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}")
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user