Files
portfolio-hub/app/services/portfolio_service.py
T
genichin 829629fa8f feat: implement portfolio UUID migration and v1 API finalization (BAT-006, BAT-001 re-verified)
Core Changes:
- Migrate portfolio_id from string to UUID in schema and repository layer
- Update portfolio CLI commands to use name+user combination for identification
- Update HTTP API to accept portfolio_id as UUID path parameter
- Implement portfolio ownership validation in repository queries

Requirements Completed (REQ-016 to REQ-019):
- REQ-016: portfolios table UUID schema migration with data migration script
- REQ-017: CLI portfolio commands with name+user identification
- REQ-018: HTTP API portfolio path parameters and v1 asset_id UUID returns
- REQ-019: Portfolio UUID baseline documentation updates

BAT-001 Re-verification:
- REQ-004~010 marked as Implemented after verification evidence validation
- All v1 API endpoints verified with comprehensive test coverage

Documentation Updates:
- Update data-model.md with portfolio UUID structure
- Update interface-contract.md with UUID path parameters
- Update runtime-flows.md with ownership-based resolution flows
- Update project-structure.md with database layer changes
- Add migration script for portfolio UUID transition

Testing:
- Add test_portfolio_uuid.py for migration validation
- Update test_cli.py and test_portfolio_ownership.py for ownership checks
2026-05-11 09:27:57 +00:00

363 lines
14 KiB
Python

"""Portfolio service orchestration."""
from __future__ import annotations
import json
from fastapi import Depends
from app.core.config import Settings, get_settings
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet
from app.domain.services import (
apply_buy,
apply_delete,
apply_invest,
apply_reset,
apply_sell,
apply_transfer,
get_portfolio_status,
)
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.portfolio import (
BuyRequest,
CommentCreateRequest,
CommentResponse,
CommentUpdateRequest,
CreatePortfolioRequest,
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
PortfolioTagMutationRequest,
PortfolioTagResponse,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
UpdatePortfolioRequest,
)
def _infer_symbol(portfolio_id: str) -> str:
parts = [part.strip() for part in portfolio_id.split(":") if part.strip()]
return parts[0] if parts else ""
def _to_portfolio_summary(portfolio: Portfolio):
from app.schemas.portfolio import PortfolioSummary
return PortfolioSummary(
portfolio_id=portfolio.portfolio_id,
broker=portfolio.broker,
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,
price=portfolio.price,
profit_buffer=portfolio.profit_buffer,
active_strategy_id=portfolio.active_strategy_id,
avabile_strategies=portfolio.avabile_strategies,
created_at=portfolio.created_at,
updated_at=portfolio.updated_at,
last_trade_at=portfolio.last_trade_at,
)
def _parse_strategy_config(config: str) -> dict[str, object]:
try:
parsed = json.loads(config) if config else {}
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _merge_strategy_ids(active_strategy_id: str | None, strategy_ids: list[str] | None) -> list[str] | None:
normalized = list(strategy_ids or [])
if active_strategy_id and active_strategy_id not in normalized:
normalized.append(active_strategy_id)
return normalized or None
def _to_strategy_set_response(strategy_set: StrategySet | None):
from app.schemas.portfolio import StrategySetResponse
if strategy_set is None:
return None
return StrategySetResponse(
id=strategy_set.id,
name=strategy_set.name,
config=strategy_set.config,
nick=strategy_set.nick,
created_at=strategy_set.created_at,
)
def _to_ledger_entry_response(entry: LedgerEntry):
from app.schemas.portfolio import LedgerEntryResponse
return LedgerEntryResponse(
portfolio_id=entry.portfolio_id,
recorded_at=entry.recorded_at,
action=entry.action,
price=entry.price,
quantity=entry.quantity,
fee_amount=entry.fee_amount,
tax_amount=entry.tax_amount,
cash_balance_after=entry.cash_balance_after,
asset_quantity_after=entry.asset_quantity_after,
total_value_after=entry.total_value_after,
profit_buffer_after=entry.profit_buffer_after,
strategy_set_id=entry.strategy_set_id,
note=entry.note,
)
def _to_comment_response(comment: PortfolioComment) -> CommentResponse:
return CommentResponse(
id=comment.id,
portfolio_id=comment.portfolio_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
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),
recent_entries=[_to_ledger_entry_response(entry) for entry in status_result.recent_entries],
strategy_set=_to_strategy_set_response(status_result.strategy_set),
live_quantity=status_result.live_quantity,
)
def get_portfolio_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
return get_portfolio_repository_from_settings(settings)
class PortfolioService:
def __init__(self, repository: PortfolioRepository) -> None:
self._repository = repository
def list_portfolios(
self,
*,
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]:
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(),
total_value=float(portfolio.cash_balance) + float(portfolio.asset_quantity) * float(portfolio.price),
)
for portfolio in portfolios
]
def get_portfolio_status(self, *, portfolio_id: str, recent_trades: int = 5) -> PortfolioDetailResponse:
status_result = get_portfolio_status(self._repository, portfolio_id, recent_trades=recent_trades)
return _to_detail_response(status_result)
def create_portfolio(
self,
payload: CreatePortfolioRequest,
*,
owner_user_id: str = "system",
) -> PortfolioDetailResponse:
active_strategy_id = payload.active_strategy_id
if active_strategy_id is None:
strategy_set = self._repository.get_or_create_strategy_set(
payload.strategy_name,
json.dumps(payload.strategy_params, sort_keys=True, ensure_ascii=True),
)
active_strategy_id = strategy_set.id
avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies)
portfolio = self._repository.create_portfolio(
payload.name,
owner_user_id=owner_user_id,
broker=payload.broker,
status=payload.status,
symbol=payload.symbol,
cash_balance=payload.cash_balance,
asset_quantity=payload.asset_quantity,
asset_price=payload.asset_price,
profit_buffer=payload.profit_buffer,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
)
return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5)
def update_portfolio(self, portfolio_id: str, payload: UpdatePortfolioRequest) -> PortfolioDetailResponse:
fields = payload.model_dump(exclude_none=True)
current = self._repository.get(portfolio_id)
if "strategy_name" in fields or "strategy_params" in fields:
current_strategy_set = None
if current.active_strategy_id:
current_strategy_set = self._repository.get_strategy_set(current.active_strategy_id)
strategy_name = fields.get("strategy_name")
if strategy_name is None and current_strategy_set is not None:
strategy_name = current_strategy_set.name
if strategy_name is None:
raise ValueError("strategy_name is required when no active strategy is set")
strategy_params = fields.get("strategy_params")
if strategy_params is None and current_strategy_set is not None:
strategy_params = _parse_strategy_config(current_strategy_set.config)
if strategy_params is None:
strategy_params = {}
strategy_set = self._repository.get_or_create_strategy_set(
strategy_name,
json.dumps(strategy_params, sort_keys=True, ensure_ascii=True),
)
fields["active_strategy_id"] = strategy_set.id
fields["avabile_strategies"] = _merge_strategy_ids(
strategy_set.id,
fields.get("avabile_strategies", current.avabile_strategies),
)
elif "active_strategy_id" in fields:
fields["avabile_strategies"] = _merge_strategy_ids(
fields.get("active_strategy_id"),
fields.get("avabile_strategies", current.avabile_strategies),
)
fields.pop("strategy_name", None)
fields.pop("strategy_params", None)
self._repository.update_portfolio(portfolio_id, **fields)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def buy(self, portfolio_id: str, payload: BuyRequest) -> PortfolioDetailResponse:
apply_buy(
self._repository,
portfolio_id,
quantity=payload.quantity,
price=payload.price,
fee=payload.fee,
tax=payload.tax,
note=payload.note,
)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def sell(self, portfolio_id: str, payload: SellRequest) -> PortfolioDetailResponse:
apply_sell(
self._repository,
portfolio_id,
quantity=payload.quantity,
price=payload.price,
fee=payload.fee,
tax=payload.tax,
note=payload.note,
)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def invest(self, portfolio_id: str, payload: InvestRequest) -> PortfolioDetailResponse:
apply_invest(
self._repository,
portfolio_id,
movement_type=payload.movement_type,
amount=payload.amount,
note=payload.note,
)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def transfer(self, portfolio_id: str, payload: TransferRequest) -> PortfolioDetailResponse:
apply_transfer(
self._repository,
portfolio_id,
direction=payload.direction,
amount=payload.amount,
note=payload.note,
)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def reset(self, portfolio_id: str, payload: ResetPortfolioRequest) -> PortfolioDetailResponse:
infer_symbol = payload.infer_symbol or _infer_symbol(portfolio_id)
apply_reset(self._repository, portfolio_id, infer_symbol=lambda _: infer_symbol)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
def delete(self, portfolio_id: str, *, keep_ledger: bool = False) -> None:
apply_delete(self._repository, portfolio_id, clear_ledger=not keep_ledger)
def create_comment(self, portfolio_id: str, payload: CommentCreateRequest) -> CommentResponse:
self._repository.get(portfolio_id)
comment = self._repository.create_comment(portfolio_id, content=payload.content)
return _to_comment_response(comment)
def list_comments(self, portfolio_id: str) -> list[CommentResponse]:
self._repository.get(portfolio_id)
comments = self._repository.list_comments(portfolio_id)
return [_to_comment_response(c) for c in comments]
def update_comment(self, portfolio_id: str, comment_id: str, payload: CommentUpdateRequest) -> CommentResponse:
self._repository.get(portfolio_id)
comment = self._repository.update_comment(portfolio_id, comment_id, content=payload.content)
return _to_comment_response(comment)
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
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),
) -> PortfolioService:
return PortfolioService(repository)