from __future__ import annotations from datetime import datetime, timezone import pytest from app.domain.models import LedgerEntry, Portfolio, StrategySet from app.domain.services import apply_buy, apply_invest, apply_reset, apply_sell, apply_transfer, get_portfolio_status class FakeRepo: def __init__(self, portfolio: Portfolio | None = None) -> None: self._portfolio = portfolio self.entries: list[LedgerEntry] = [] self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set} self.last_delete: tuple[str, bool] | None = None def get(self, portfolio_id: str) -> Portfolio: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") return self._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: self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, name=name or symbol, symbol=symbol, owner_user_id=owner_user_id, cash_balance=initial_cash, active_strategy_id=active_strategy_id, avabile_strategies=avabile_strategies, ) return self._portfolio def create_portfolio(self, *args, **kwargs): raise NotImplementedError() def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: portfolio = self.get(portfolio_id) for key, value in fields.items(): if value is not None and hasattr(portfolio, key): setattr(portfolio, key, value) return portfolio 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[Portfolio]: if self._portfolio is None: return [] if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: return [] return [self._portfolio] def add_tag(self, portfolio_id: str, *, tag: str): raise NotImplementedError() def list_tags(self, portfolio_id: str): return [] def remove_tag(self, portfolio_id: str, *, tag: str) -> None: raise NotImplementedError() def get_strategy_set(self, id: str) -> StrategySet | None: return self.strategy_set if id == self.strategy_set.id else None def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") self._strategy_sets[self.strategy_set.id] = self.strategy_set return self.strategy_set def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]: return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True) def delete_strategy_set(self, id: str) -> None: # In tests, just remove from the fake storage if id in self._strategy_sets: del self._strategy_sets[id] 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: self._portfolio = portfolio self.entries.insert( 0, LedgerEntry( portfolio_id=portfolio.portfolio_id, recorded_at=datetime.now(timezone.utc), action=action, price=trade_price, quantity=quantity, cash_balance_after=portfolio.cash_balance, asset_quantity_after=portfolio.asset_quantity, total_value_after=total_value_after, fee_amount=fee_amount, tax_amount=tax_amount, profit_buffer_after=portfolio.profit_buffer, strategy_set_id=strategy_set_id, note=note, ), ) def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: return self.entries[:limit] def reset(self, portfolio_id: str) -> Portfolio: portfolio = self.get(portfolio_id) portfolio.cash_balance = 0.0 portfolio.asset_quantity = 0.0 portfolio.price = 0.0 portfolio.profit_buffer = 0.0 portfolio.active_strategy_id = None self.entries = [] return portfolio def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: portfolio = self.get(portfolio_id) if action == "INVEST": portfolio.profit_buffer += amount else: if portfolio.profit_buffer < amount: raise ValueError("수익버퍼 잔액이 부족합니다") portfolio.profit_buffer -= amount return portfolio def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: portfolio = self.get(portfolio_id) if direction == "to-buffer": if portfolio.cash_balance < amount: raise ValueError("이체 금액이 현금 잔고를 초과합니다") portfolio.cash_balance -= amount portfolio.profit_buffer += amount else: if portfolio.profit_buffer < amount: raise ValueError("이체 금액이 수익버퍼 잔고를 초과합니다") portfolio.cash_balance += amount portfolio.profit_buffer -= amount return portfolio def delete(self, portfolio_id: str, clear_ledger: bool = True) -> None: self.last_delete = (portfolio_id, clear_ledger) self._portfolio = None def _portfolio() -> Portfolio: return Portfolio( portfolio_id="p1", broker="kis", status="enabled", name="Samsung Electronics", symbol="005930", cash_balance=1_000_000.0, asset_quantity=0.0, price=70_000.0, profit_buffer=0.0, active_strategy_id="set-1", avabile_strategies=["set-1"], ) def test_apply_buy_updates_cash_and_quantity() -> None: repo = FakeRepo(_portfolio()) updated = apply_buy(repo, "p1", quantity=2, price=50_000, fee=100, tax=0, note="buy") assert updated.cash_balance == pytest.approx(899_900.0) assert updated.asset_quantity == pytest.approx(2.0) assert repo.entries[0].action == "BUY" def test_apply_sell_updates_cash_and_quantity() -> None: portfolio = _portfolio() portfolio.asset_quantity = 3.0 repo = FakeRepo(portfolio) updated = apply_sell(repo, "p1", quantity=1, price=80_000, fee=100, tax=50, note="sell") assert updated.cash_balance == pytest.approx(1_079_850.0) assert updated.asset_quantity == pytest.approx(2.0) assert repo.entries[0].action == "SELL" def test_apply_invest_deposit_updates_buffer() -> None: repo = FakeRepo(_portfolio()) updated = apply_invest(repo, "p1", movement_type="deposit", amount=50_000, note="deposit") assert updated.profit_buffer == pytest.approx(50_000.0) def test_apply_transfer_to_buffer_moves_cash() -> None: repo = FakeRepo(_portfolio()) updated = apply_transfer(repo, "p1", direction="to-buffer", amount=100_000, note="shift") assert updated.cash_balance == pytest.approx(900_000.0) assert updated.profit_buffer == pytest.approx(100_000.0) def test_apply_reset_creates_when_missing() -> None: repo = FakeRepo(None) updated, created = apply_reset(repo, "005930:tv", infer_symbol=lambda portfolio_id: portfolio_id.split(":")[0]) assert created is True assert updated.symbol == "005930" def test_get_portfolio_status_includes_strategy_set_and_entries() -> None: repo = FakeRepo(_portfolio()) apply_buy(repo, "p1", quantity=1, price=50_000) result = get_portfolio_status(repo, "p1", recent_trades=5) assert result.portfolio.portfolio_id == "p1" assert len(result.recent_entries) == 1 assert result.strategy_set is not None