chore: initial project scaffold and gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Domain models and services for PortfolioHub."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Portfolio domain models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySet:
|
||||
id: str
|
||||
strategy_name: str
|
||||
strategy_config: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Portfolio:
|
||||
portfolio_id: str
|
||||
broker: str
|
||||
status: str
|
||||
symbol: str
|
||||
strategy_name: str
|
||||
strategy_params: dict[str, Any] = field(default_factory=dict)
|
||||
cash_balance: float = 0.0
|
||||
asset_quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
profit_buffer: float = 0.0
|
||||
current_strategy_set_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
portfolio_id: str
|
||||
recorded_at: datetime
|
||||
action: str
|
||||
price: float
|
||||
quantity: float
|
||||
cash_balance_after: float
|
||||
asset_quantity_after: float
|
||||
total_value_after: float
|
||||
fee_amount: float = 0.0
|
||||
tax_amount: float = 0.0
|
||||
profit_buffer_after: float = 0.0
|
||||
strategy_set_id: str = "legacy"
|
||||
note: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LiveAssetSource(Protocol):
|
||||
def get_asset_quantity(self, symbol: str) -> float: ...
|
||||
@@ -0,0 +1,280 @@
|
||||
"""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
|
||||
|
||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
||||
_INVEST_ACTION_MAP = {
|
||||
"deposit": "INVEST",
|
||||
"profit": "PROFIT",
|
||||
"withdraw": "WITHDRAW",
|
||||
}
|
||||
|
||||
|
||||
class PortfolioDomainRepository(Protocol):
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
def get_or_create(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
symbol: str,
|
||||
*,
|
||||
broker: str = "kis",
|
||||
status: str = "enabled",
|
||||
strategy_name: str = "",
|
||||
strategy_params: dict | None = None,
|
||||
initial_cash: float = 0.0,
|
||||
) -> Portfolio: ...
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
broker: str,
|
||||
status: str,
|
||||
symbol: str,
|
||||
strategy_name: str,
|
||||
strategy_params: dict,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
profit_buffer: float,
|
||||
) -> Portfolio: ...
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
|
||||
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
def get_or_create_strategy_set(self, strategy_name: str, strategy_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,
|
||||
symbol=portfolio.symbol,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
cash_balance=float(portfolio.cash_balance) - total_cost,
|
||||
asset_quantity=float(portfolio.asset_quantity) + quantity,
|
||||
price=price,
|
||||
profit_buffer=float(portfolio.profit_buffer),
|
||||
current_strategy_set_id=portfolio.current_strategy_set_id,
|
||||
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.current_strategy_set_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,
|
||||
symbol=portfolio.symbol,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
cash_balance=float(portfolio.cash_balance) + net_proceeds,
|
||||
asset_quantity=float(portfolio.asset_quantity) - quantity,
|
||||
price=price,
|
||||
profit_buffer=float(portfolio.profit_buffer),
|
||||
current_strategy_set_id=portfolio.current_strategy_set_id,
|
||||
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.current_strategy_set_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) != _NOT_FOUND_MSG:
|
||||
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.current_strategy_set_id:
|
||||
strategy_set = repo.get_strategy_set(portfolio.current_strategy_set_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,
|
||||
)
|
||||
Reference in New Issue
Block a user