chore: initial project scaffold and gitignore

This commit is contained in:
2026-04-03 07:33:32 +00:00
commit 3e3acbd13d
41 changed files with 3658 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
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", strategy_name="target_value", strategy_config="{}")
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,
*,
broker: str = "kis",
status: str = "enabled",
strategy_name: str = "",
strategy_params: dict | None = None,
initial_cash: float = 0.0,
) -> Portfolio:
self._portfolio = Portfolio(
portfolio_id=portfolio_id,
broker=broker,
status=status,
symbol=symbol,
strategy_name=strategy_name,
strategy_params=strategy_params or {},
cash_balance=initial_cash,
)
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) -> list[Portfolio]:
if self._portfolio is None:
return []
return [self._portfolio]
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, strategy_name: str, strategy_config: str) -> StrategySet:
self.strategy_set = StrategySet(id="set-1", strategy_name=strategy_name, strategy_config=strategy_config)
return self.strategy_set
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.current_strategy_set_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",
symbol="005930",
strategy_name="target_value",
strategy_params={"target_asset_ratio": 0.7},
cash_balance=1_000_000.0,
asset_quantity=0.0,
price=70_000.0,
profit_buffer=0.0,
current_strategy_set_id="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