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>
586 lines
21 KiB
Python
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,
|
|
)
|