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>
This commit is contained in:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
+496 -4
View File
@@ -1,14 +1,32 @@
"""v1 portfolio tag endpoints — apply and remove tags (REQ-005)."""
"""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, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_trade_write
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 TagApplyRequest, TagApplyResponse
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"])
@@ -24,6 +42,229 @@ def _close_repo(repo) -> 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,
@@ -43,6 +284,9 @@ def apply_portfolio_tag(
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:
@@ -82,6 +326,9 @@ def remove_portfolio_tag(
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:
@@ -91,3 +338,248 @@ def remove_portfolio_tag(
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,
)