Files
portfolio-hub/app/domain/services.py
T

300 lines
9.1 KiB
Python

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