- Add acceptance criteria documentation (AC-0013 through AC-0023) - Add Line-0003 discovery documentation - Implement Strategy Catalog v1 with new database migration - Update portfolio API endpoints and schemas - Refactor strategy and portfolio services - Add strategy management CLI commands - Update domain models with strategy catalog support - Add comprehensive tests for v1 strategies - Update design documentation (data model, interfaces, runtime flows)
250 lines
8.7 KiB
Python
250 lines
8.7 KiB
Python
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
|