Files
portfolio-hub/app/api/v1_portfolios.py
T
genichinandClaude Sonnet 4.6 4bf6b554a7 feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)
dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:

- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
  - _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
  - GET/POST/DELETE /v1/portfolios/{id}/tags
  - POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
  - POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
  - v1_ledger.py 제거, disabled 차단(409) 추가
  - test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
  test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 07:25:39 +00:00

586 lines
21 KiB
Python

"""v1 portfolio CRUD and tag endpoints (REQ-005, REQ-025, REQ-026, REQ-027, REQ-028)."""
from __future__ import annotations
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.domain.models import Portfolio, PortfolioComment
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import (
LedgerTradeRequest,
LedgerTradeResponse,
PortfolioCommentItem,
PortfolioCommentListResponse,
PortfolioCommentRequest,
PortfolioCommentUpdateRequest,
PortfolioCreateRequest,
PortfolioItem,
PortfolioListResponse,
PortfolioTagListResponse,
PortfolioUpdateRequest,
TagApplyRequest,
TagApplyResponse,
TagItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
def _close_repo(repo) -> None:
if callable(getattr(type(repo), "close", None)):
repo.close()
def _require_write_token(auth: AuthContext) -> None:
if auth.token_type in ("service", "client"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{auth.token_type} token cannot create or modify portfolios",
)
def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem:
return PortfolioItem(
portfolio_id=portfolio.portfolio_id,
asset_id=portfolio.asset_id,
owner_user_id=portfolio.owner_user_id,
broker=portfolio.broker,
status=portfolio.status,
name=portfolio.name,
cash_balance=portfolio.cash_balance,
asset_quantity=portfolio.asset_quantity,
price=portfolio.price,
profit_buffer=portfolio.profit_buffer,
tag_ids=[str(t) for t in (portfolio.tag_ids or [])],
managed_by_daemon_id=portfolio.managed_by_daemon_id,
created_at=portfolio.created_at,
updated_at=portfolio.updated_at,
)
def _comment_to_item(comment: PortfolioComment) -> PortfolioCommentItem:
return PortfolioCommentItem(
comment_id=comment.id,
portfolio_id=comment.portfolio_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio:
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
if auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
return portfolio
# ---------------------------------------------------------------------------
# POST /v1/portfolios — REQ-025
# ---------------------------------------------------------------------------
@router.post("", response_model=PortfolioItem, status_code=status.HTTP_201_CREATED)
def create_portfolio_v1(
body: PortfolioCreateRequest,
auth: AuthContext = Depends(require_read),
) -> PortfolioItem:
_require_write_token(auth)
owner_user_id: str = (
body.user_id if (auth.permission == Permission.ADMIN and body.user_id) else auth.user_id
) or "system"
repo = _get_repo()
try:
asset = repo.get_asset_by_id(body.asset_id)
if asset is None or asset.status != "active":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"asset not found or inactive: {body.asset_id}",
)
name = body.name or f"{body.broker}-{body.asset_id[:8]}"
strategy_set = repo.get_or_create_strategy_set(
body.strategy_name or "target_value",
json.dumps(body.strategy_params or {}, sort_keys=True, ensure_ascii=True),
)
portfolio = repo.create_portfolio(
str(uuid.uuid4()),
owner_user_id=owner_user_id,
broker=body.broker,
status="enabled",
name=name,
asset_id=body.asset_id,
cash_balance=body.cash_balance,
asset_quantity=body.asset_quantity,
asset_price=body.asset_price,
profit_buffer=body.profit_buffer,
active_strategy_id=strategy_set.id,
avabile_strategies=[strategy_set.id],
)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _portfolio_to_item(portfolio)
# ---------------------------------------------------------------------------
# PATCH /v1/portfolios/{portfolio_id} — REQ-026
# ---------------------------------------------------------------------------
@router.patch("/{portfolio_id}", response_model=PortfolioItem)
def update_portfolio_v1(
portfolio_id: str,
body: PortfolioUpdateRequest,
auth: AuthContext = Depends(require_read),
) -> PortfolioItem:
_require_write_token(auth)
repo = _get_repo()
try:
portfolio = _get_portfolio_or_404(auth, portfolio_id, repo)
if portfolio.status == "disabled":
non_status_fields = {k for k, v in body.model_dump(exclude_none=True).items() if k != "status"}
if non_status_fields:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="disabled portfolio: only status restoration is allowed",
)
fields = body.model_dump(exclude_none=True)
if fields:
if "strategy_name" in fields or "strategy_params" in fields:
strategy_set = repo.get_or_create_strategy_set(
fields.pop("strategy_name", None) or "target_value",
json.dumps(fields.pop("strategy_params", None) or {}, sort_keys=True, ensure_ascii=True),
)
fields["active_strategy_id"] = strategy_set.id
fields["avabile_strategies"] = [strategy_set.id]
else:
fields.pop("strategy_name", None)
fields.pop("strategy_params", None)
repo.update_portfolio(portfolio_id, **fields)
portfolio = repo.get(portfolio_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _portfolio_to_item(portfolio)
# ---------------------------------------------------------------------------
# DELETE /v1/portfolios/{portfolio_id} — REQ-027
# ---------------------------------------------------------------------------
@router.delete("/{portfolio_id}", response_model=PortfolioItem)
def delete_portfolio_v1(
portfolio_id: str,
auth: AuthContext = Depends(require_read),
) -> PortfolioItem:
_require_write_token(auth)
repo = _get_repo()
try:
_get_portfolio_or_404(auth, portfolio_id, repo)
portfolio = repo.soft_delete_portfolio(portfolio_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _portfolio_to_item(portfolio)
# ---------------------------------------------------------------------------
# GET /v1/portfolios — REQ-028
# ---------------------------------------------------------------------------
@router.get("", response_model=PortfolioListResponse)
def list_portfolios_v1(
market: str | None = Query(default=None),
include_disabled: bool = Query(default=False),
user_id: str | None = Query(default=None),
auth: AuthContext = Depends(require_read),
) -> PortfolioListResponse:
if auth.token_type == "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="client token cannot list portfolios")
status_filter: str | None = None if include_disabled else "enabled"
repo = _get_repo()
try:
if auth.token_type == "service":
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="service token without daemon_id cannot list portfolios",
)
portfolios = repo.list_portfolios(
managed_by_daemon_id=auth.daemon_id,
market=market,
status=status_filter,
)
else:
if auth.permission == Permission.ADMIN and user_id is not None:
owner = user_id
else:
owner = auth.user_id
portfolios = repo.list_portfolios(
owner_user_id=owner,
market=market,
status=status_filter,
)
except HTTPException:
raise
finally:
_close_repo(repo)
items = [_portfolio_to_item(p) for p in portfolios]
return PortfolioListResponse(portfolios=items, total=len(items))
@router.post("/{portfolio_id}/tags", response_model=TagApplyResponse)
def apply_portfolio_tag(
portfolio_id: str,
body: TagApplyRequest,
auth: AuthContext = Depends(require_trade_write),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
is_admin = auth.permission == Permission.ADMIN
is_owner = portfolio.owner_user_id == auth.user_id
if not is_admin and not is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
_require_write_token(auth)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
tag = repo.get_tag_by_id(body.tag_id)
if tag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}")
if not tag.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
if not is_admin and not tag.is_admin_tag and tag.created_by_user_id != portfolio.owner_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="태그 적용 정책을 위반했습니다")
repo.append_portfolio_tag_id(portfolio_id, body.tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return TagApplyResponse(tag_id=body.tag_id, status="applied")
@router.delete("/{portfolio_id}/tags/{tag_id}", response_model=TagApplyResponse)
def remove_portfolio_tag(
portfolio_id: str,
tag_id: str,
auth: AuthContext = Depends(require_trade_write),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
is_admin = auth.permission == Permission.ADMIN
is_owner = portfolio.owner_user_id == auth.user_id
if not is_admin and not is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
_require_write_token(auth)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
repo.remove_portfolio_tag_id(portfolio_id, tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return TagApplyResponse(tag_id=tag_id, status="removed")
# ---------------------------------------------------------------------------
# GET /v1/portfolios/{portfolio_id}/tags — REQ-030
# ---------------------------------------------------------------------------
@router.get("/{portfolio_id}/tags", response_model=PortfolioTagListResponse)
def get_portfolio_tags(
portfolio_id: str,
auth: AuthContext = Depends(require_read),
) -> PortfolioTagListResponse:
if auth.token_type in ("service", "client"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{auth.token_type} token cannot access portfolio tags",
)
repo = _get_repo()
try:
portfolio = _get_portfolio_or_404(auth, portfolio_id, repo)
tags_dict = repo.get_tags_by_ids([str(t) for t in (portfolio.tag_ids or [])])
tag_items = [
TagItem(
tag_id=t.tag_id,
name=t.name,
is_admin_tag=t.is_admin_tag,
is_active=t.is_active,
created_at=t.created_at,
)
for t in tags_dict.values()
]
except HTTPException:
raise
finally:
_close_repo(repo)
return PortfolioTagListResponse(tags=tag_items, total=len(tag_items))
# ---------------------------------------------------------------------------
# POST /v1/portfolios/{portfolio_id}/comments — REQ-031
# ---------------------------------------------------------------------------
@router.post("/{portfolio_id}/comments", response_model=PortfolioCommentItem, status_code=status.HTTP_201_CREATED)
def create_portfolio_comment(
portfolio_id: str,
body: PortfolioCommentRequest,
auth: AuthContext = Depends(require_read),
) -> PortfolioCommentItem:
_require_write_token(auth)
repo = _get_repo()
try:
portfolio = _get_portfolio_or_404(auth, portfolio_id, repo)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
comment = repo.create_comment(portfolio_id, content=body.content)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _comment_to_item(comment)
# ---------------------------------------------------------------------------
# GET /v1/portfolios/{portfolio_id}/comments — REQ-031
# ---------------------------------------------------------------------------
@router.get("/{portfolio_id}/comments", response_model=PortfolioCommentListResponse)
def list_portfolio_comments(
portfolio_id: str,
auth: AuthContext = Depends(require_read),
) -> PortfolioCommentListResponse:
if auth.token_type in ("service", "client"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{auth.token_type} token cannot access portfolio comments",
)
repo = _get_repo()
try:
_get_portfolio_or_404(auth, portfolio_id, repo)
comments = repo.list_comments(portfolio_id)
except HTTPException:
raise
finally:
_close_repo(repo)
items = [_comment_to_item(c) for c in comments]
return PortfolioCommentListResponse(comments=items, total=len(items))
# ---------------------------------------------------------------------------
# PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031
# ---------------------------------------------------------------------------
@router.patch("/{portfolio_id}/comments/{comment_id}", response_model=PortfolioCommentItem)
def update_portfolio_comment(
portfolio_id: str,
comment_id: str,
body: PortfolioCommentUpdateRequest,
auth: AuthContext = Depends(require_read),
) -> PortfolioCommentItem:
_require_write_token(auth)
repo = _get_repo()
try:
portfolio = _get_portfolio_or_404(auth, portfolio_id, repo)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
comment = repo.update_comment(portfolio_id, comment_id, content=body.content)
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}")
finally:
_close_repo(repo)
return _comment_to_item(comment)
# ---------------------------------------------------------------------------
# DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031
# ---------------------------------------------------------------------------
@router.delete("/{portfolio_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_portfolio_comment(
portfolio_id: str,
comment_id: str,
auth: AuthContext = Depends(require_read),
) -> None:
_require_write_token(auth)
repo = _get_repo()
try:
portfolio = _get_portfolio_or_404(auth, portfolio_id, repo)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
repo.delete_comment(portfolio_id, comment_id)
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}")
finally:
_close_repo(repo)
# ---------------------------------------------------------------------------
# POST /v1/portfolios/{portfolio_id}/ledger/trades — REQ-029
# ---------------------------------------------------------------------------
def _require_service_token(auth: AuthContext = Depends(require_trade_write)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id not assigned to this token",
)
return auth
def _payloads_match(existing, body: LedgerTradeRequest) -> bool:
return (
existing.side == body.side
and abs(existing.quantity - float(body.quantity)) < 1e-9
and abs(existing.price - float(body.price)) < 1e-9
and abs(existing.fee_amount - float(body.fee_amount)) < 1e-9
and abs(existing.tax_amount - float(body.tax_amount)) < 1e-9
)
@router.post(
"/{portfolio_id}/ledger/trades",
response_model=LedgerTradeResponse,
status_code=status.HTTP_201_CREATED,
)
def record_ledger_trade_v1(
portfolio_id: str,
body: LedgerTradeRequest,
response: Response,
auth: AuthContext = Depends(_require_service_token),
) -> LedgerTradeResponse:
repo = _get_repo()
try:
try:
portfolio = repo.get(portfolio_id)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}",
)
if portfolio.status == "disabled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio")
if portfolio.managed_by_daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Asset is not assigned to any daemon",
)
if portfolio.managed_by_daemon_id != auth.daemon_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token daemon does not match asset daemon assignment",
)
trade, created = repo.insert_ledger_trade(
asset_id=portfolio_id,
order_id=body.order_id,
execution_id=body.execution_id,
side=body.side,
quantity=float(body.quantity),
price=float(body.price),
executed_at=body.executed_at,
fee_amount=float(body.fee_amount),
tax_amount=float(body.tax_amount),
)
except HTTPException:
raise
finally:
_close_repo(repo)
if not created:
if not _payloads_match(trade, body):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate key with different payload",
)
response.status_code = status.HTTP_200_OK
trade_status = "duplicate"
else:
trade_status = "new"
return LedgerTradeResponse(
ledger_entry_id=trade.ledger_entry_id,
asset_id=trade.asset_id,
order_id=trade.order_id,
execution_id=trade.execution_id,
side=trade.side,
quantity=trade.quantity,
price=trade.price,
fee_amount=trade.fee_amount,
tax_amount=trade.tax_amount,
executed_at=trade.executed_at,
recorded_at=trade.recorded_at,
status=trade_status,
)