"""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)