"""Portfolio domain services ported from jsbot-kis.""" from __future__ import annotations from dataclasses import dataclass from typing import Callable, Protocol from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet PORTFOLIO_NOT_FOUND_MESSAGE = "포트폴리오를 찾을 수 없습니다" _INVEST_ACTION_MAP = { "deposit": "INVEST", "profit": "PROFIT", "withdraw": "WITHDRAW", } def normalize_expiry_days(expires_in_days: int | None, *, default_days: int) -> int: days = default_days if expires_in_days is None else int(expires_in_days) if days <= 0: raise ValueError("만료일은 1일 이상이어야 합니다") return days class PortfolioDomainRepository(Protocol): def get(self, portfolio_id: str) -> Portfolio: ... def get_or_create( self, portfolio_id: str, symbol: str, *, name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, avabile_strategies: list[str] | None = None, owner_user_id: str | None = None, initial_cash: float = 0.0, ) -> Portfolio: ... def create_portfolio( self, name: str, *, owner_user_id: str, broker: str, status: str, symbol: str, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: ... def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ... def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... def list_portfolios( self, *, broker: str | None = None, status: str | None = None, owner_user_id: str | None = None, ) -> list[Portfolio]: ... def get_strategy_set(self, id: str) -> StrategySet | None: ... def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ... def save( self, portfolio: Portfolio, *, current_price: float, action: str, trade_price: float, quantity: float, fee_amount: float, tax_amount: float, total_value_after: float, strategy_set_id: str = "manual", note: str = "", ) -> None: ... def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ... def reset(self, portfolio_id: str) -> Portfolio: ... def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ... def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: ... def delete(self, portfolio_id: str, clear_ledger: bool = True) -> None: ... def apply_buy( repo: PortfolioDomainRepository, portfolio_id: str, *, quantity: float, price: float, fee: float = 0.0, tax: float = 0.0, note: str = "", ) -> Portfolio: quantity = float(quantity) price = float(price) fee = float(fee) tax = float(tax) if quantity <= 0: raise ValueError("quantity는 0보다 커야 합니다") if price <= 0: raise ValueError("price는 0보다 커야 합니다") if fee < 0 or tax < 0: raise ValueError("fee/tax는 음수가 될 수 없습니다") portfolio = repo.get(portfolio_id) gross = quantity * price total_cost = gross + fee + tax if float(portfolio.cash_balance) < total_cost: raise ValueError("현금 잔고가 부족합니다") updated = Portfolio( portfolio_id=portfolio.portfolio_id, broker=portfolio.broker, status=portfolio.status, name=portfolio.name, symbol=portfolio.symbol, owner_user_id=portfolio.owner_user_id, cash_balance=float(portfolio.cash_balance) - total_cost, asset_quantity=float(portfolio.asset_quantity) + quantity, price=price, profit_buffer=float(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, ) total_value_after = float(updated.cash_balance) + float(updated.asset_quantity) * price repo.save( updated, current_price=price, action="BUY", trade_price=price, quantity=quantity, fee_amount=fee, tax_amount=tax, total_value_after=total_value_after, strategy_set_id=portfolio.active_strategy_id or "manual", note=note, ) return repo.get(portfolio_id) def apply_sell( repo: PortfolioDomainRepository, portfolio_id: str, *, quantity: float, price: float, fee: float = 0.0, tax: float = 0.0, note: str = "", ) -> Portfolio: quantity = float(quantity) price = float(price) fee = float(fee) tax = float(tax) if quantity <= 0: raise ValueError("quantity는 0보다 커야 합니다") if price <= 0: raise ValueError("price는 0보다 커야 합니다") if fee < 0 or tax < 0: raise ValueError("fee/tax는 음수가 될 수 없습니다") portfolio = repo.get(portfolio_id) if float(portfolio.asset_quantity) < quantity: raise ValueError("보유 수량이 부족합니다") gross = quantity * price net_proceeds = gross - fee - tax updated = Portfolio( portfolio_id=portfolio.portfolio_id, broker=portfolio.broker, status=portfolio.status, name=portfolio.name, symbol=portfolio.symbol, owner_user_id=portfolio.owner_user_id, cash_balance=float(portfolio.cash_balance) + net_proceeds, asset_quantity=float(portfolio.asset_quantity) - quantity, price=price, profit_buffer=float(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, ) total_value_after = float(updated.cash_balance) + float(updated.asset_quantity) * price repo.save( updated, current_price=price, action="SELL", trade_price=price, quantity=quantity, fee_amount=fee, tax_amount=tax, total_value_after=total_value_after, strategy_set_id=portfolio.active_strategy_id or "manual", note=note, ) return repo.get(portfolio_id) def apply_invest( repo: PortfolioDomainRepository, portfolio_id: str, *, movement_type: str, amount: float, note: str = "", ) -> Portfolio: amount = float(amount) if amount <= 0: raise ValueError("--amount는 0보다 커야 합니다") key = str(movement_type).lower() if key not in _INVEST_ACTION_MAP: raise ValueError("movement_type은 deposit, profit, withdraw 중 하나여야 합니다") return repo.adjust_buffer(portfolio_id, amount, _INVEST_ACTION_MAP[key], note) def apply_transfer( repo: PortfolioDomainRepository, portfolio_id: str, *, direction: str, amount: float, note: str = "", ) -> Portfolio: amount = float(amount) if amount <= 0: raise ValueError("--amount는 0보다 커야 합니다") return repo.transfer_buffer(portfolio_id, amount, direction, note) def apply_reset( repo: PortfolioDomainRepository, portfolio_id: str, *, infer_symbol: Callable[[str], str], ) -> tuple[Portfolio, bool]: try: portfolio = repo.reset(portfolio_id) return portfolio, False except ValueError as exc: if str(exc) != PORTFOLIO_NOT_FOUND_MESSAGE: raise symbol = infer_symbol(portfolio_id) portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0) return portfolio, True def apply_delete( repo: PortfolioDomainRepository, portfolio_id: str, *, clear_ledger: bool = True, ) -> None: repo.delete(portfolio_id, clear_ledger=clear_ledger) @dataclass class PortfolioStatusResult: portfolio: Portfolio recent_entries: list[LedgerEntry] strategy_set: StrategySet | None live_quantity: float | None def get_portfolio_status( repo: PortfolioDomainRepository, portfolio_id: str, *, recent_trades: int = 5, live_source: LiveAssetSource | None = None, ) -> PortfolioStatusResult: portfolio = repo.get(portfolio_id) recent_entries = repo.list_recent_trades(portfolio_id, limit=recent_trades) strategy_set = None if portfolio.active_strategy_id: strategy_set = repo.get_strategy_set(portfolio.active_strategy_id) live_quantity = None if live_source is not None: live_quantity = live_source.get_asset_quantity(portfolio.symbol) return PortfolioStatusResult( portfolio=portfolio, recent_entries=recent_entries, strategy_set=strategy_set, live_quantity=live_quantity, )