diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 26d9027..4295e2e 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -1,15 +1,22 @@ -"""v1 portfolio CRUD and tag endpoints (REQ-005, REQ-025, REQ-026, REQ-027, REQ-028).""" +"""v1 portfolio CRUD, tag, trade history and PnL endpoints. + +REQ-005, REQ-025~031: Portfolio CRUD, tags, comments. +REQ-039: GET /v1/portfolios/{id}/trades — trade history with date filter. +REQ-040: GET /v1/portfolios/{id}/pnl/realized — WAC realized PnL. +""" from __future__ import annotations import json import logging import uuid +from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from app.core.auth import AuthContext, Permission, require_read, require_trade_write from app.domain.models import Portfolio, PortfolioComment +from app.domain.pnl import calculate_realized_pnl from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( LedgerTradeRequest, @@ -23,9 +30,13 @@ from app.schemas.v1 import ( PortfolioListResponse, PortfolioTagListResponse, PortfolioUpdateRequest, + RealizedPnlItem, + RealizedPnlResponse, TagApplyRequest, TagApplyResponse, TagItem, + TradeHistoryItem, + TradeHistoryResponse, ) logger = logging.getLogger(__name__) @@ -587,3 +598,199 @@ def record_ledger_trade_v1( asset_quantity_after=trade.asset_quantity_after, status=trade_status, ) + + +# --------------------------------------------------------------------------- +# Helpers for date parameter parsing (REQ-039, REQ-040) +# --------------------------------------------------------------------------- + +def _parse_date_param(value: str, param_name: str) -> datetime: + """Parse YYYY-MM-DD string to UTC datetime (start of day). Raises 422 on bad format. + + Strictly requires YYYY-MM-DD with dashes (e.g. 2026-06-15). + Python's date.fromisoformat() also accepts YYYYMMDD in 3.11+, so we + explicitly validate the format first. + """ + import re + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"날짜 형식 오류: {param_name}은 YYYY-MM-DD 형식이어야 합니다. 입력값: {value!r}", + ) + try: + d = date.fromisoformat(value) + except ValueError: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"날짜 형식 오류: {param_name}은 YYYY-MM-DD 형식이어야 합니다. 입력값: {value!r}", + ) + return datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{portfolio_id}/trades — REQ-039 +# --------------------------------------------------------------------------- + +@router.get("/{portfolio_id}/trades", response_model=TradeHistoryResponse) +def list_portfolio_trades( + portfolio_id: str, + date_param: str | None = Query(default=None, alias="date", description="단일 날짜 필터 (YYYY-MM-DD)"), + from_param: str | None = Query(default=None, alias="from", description="범위 시작 날짜 (YYYY-MM-DD, 포함)"), + to_param: str | None = Query(default=None, alias="to", description="범위 종료 날짜 (YYYY-MM-DD, 포함)"), + auth: AuthContext = Depends(require_read), +) -> TradeHistoryResponse: + """체결 내역 조회 — 날짜/범위 필터 (REQ-039). + + - `date`: YYYY-MM-DD 단일 날짜 + - `from` + `to`: 날짜 범위 (경계 포함) + - 날짜 파라미터 없으면 HTTP 422 + """ + if auth.token_type in ("service", "client"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{auth.token_type} token cannot query trade history", + ) + + # Validate date params — AC-3: at least one of date or from/to required + has_date = date_param is not None + has_range = from_param is not None or to_param is not None + + if not has_date and not has_range: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="날짜 파라미터가 필요합니다: date 또는 from/to 중 하나를 지정하세요.", + ) + + # If range is partially specified, require both + if has_range and (from_param is None or to_param is None): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="from과 to 파라미터는 둘 다 필요합니다.", + ) + + repo = _get_repo() + try: + _get_portfolio_or_404(auth, portfolio_id, repo) + + if has_date: + date_dt = _parse_date_param(date_param, "date") # type: ignore[arg-type] + trades = repo.list_trades_by_date(portfolio_id, date=date_dt) + else: + from_dt = _parse_date_param(from_param, "from") # type: ignore[arg-type] + to_dt = _parse_date_param(to_param, "to") # type: ignore[arg-type] + trades = repo.list_trades_by_date(portfolio_id, from_=from_dt, to_=to_dt) + except HTTPException: + raise + finally: + _close_repo(repo) + + items = [ + TradeHistoryItem( + ledger_entry_id=t.ledger_entry_id, + portfolio_id=t.portfolio_id, + order_id=t.order_id, + execution_id=t.execution_id, + side=t.side or "", + quantity=t.quantity, + price=t.price, + fee_amount=t.fee_amount, + tax_amount=t.tax_amount, + executed_at=t.executed_at, # type: ignore[arg-type] + recorded_at=t.recorded_at, + ) + for t in trades + ] + return TradeHistoryResponse(items=items, total=len(items)) + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{portfolio_id}/pnl/realized — REQ-040 +# --------------------------------------------------------------------------- + +@router.get("/{portfolio_id}/pnl/realized", response_model=RealizedPnlResponse) +def get_realized_pnl( + portfolio_id: str, + from_param: str | None = Query(default=None, alias="from", description="집계 시작 날짜 (YYYY-MM-DD, 필수)"), + to_param: str | None = Query(default=None, alias="to", description="집계 종료 날짜 (YYYY-MM-DD, 필수)"), + symbol: str | None = Query(default=None, description="종목 필터 (선택)"), + auth: AuthContext = Depends(require_read), +) -> RealizedPnlResponse: + """실현 PnL 집계 조회 — WAC 방식 (REQ-040). + + - `from`, `to`: 집계 기간 (필수, 경계 포함) + - `symbol`: 선택적 종목 필터 + - 응답에 data_completeness_warning (이력 불완전 시), cost_basis_from 포함 + """ + if auth.token_type in ("service", "client"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{auth.token_type} token cannot query realized PnL", + ) + + # AC-3: both from and to are required + if from_param is None or to_param is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="from과 to 파라미터는 둘 다 필요합니다.", + ) + + from_dt = _parse_date_param(from_param, "from") + to_dt = _parse_date_param(to_param, "to") + # to_dt is inclusive: extend to end of day (start of next day is used in WAC filter) + to_dt_inclusive_end = to_dt + timedelta(days=1) + + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + + # Fetch all buy/sell history for this portfolio (WAC requires full history) + ledger_entries = repo.get_all_trades_for_pnl(portfolio_id, symbol=symbol) + except HTTPException: + raise + finally: + _close_repo(repo) + + # Convert LedgerEntry objects to dicts for the domain function + # symbol is taken from the portfolio (one portfolio = one symbol) + portfolio_symbol = portfolio.symbol + + rows = [ + { + "side": e.side, + "quantity": e.quantity, + "price": e.price, + "fee_amount": e.fee_amount, + "tax_amount": e.tax_amount, + "executed_at": e.executed_at, + "order_id": e.order_id, + "execution_id": e.execution_id, + # symbol is set from portfolio; symbol filter applied in WAC function + "symbol": portfolio_symbol if symbol is None else (portfolio_symbol if portfolio_symbol == symbol else None), + } + for e in ledger_entries + ] + + result = calculate_realized_pnl(rows, from_dt=from_dt, to_dt=to_dt_inclusive_end, symbol=symbol) + + pnl_items = [ + RealizedPnlItem( + symbol=item.symbol, + executed_at=item.executed_at, + quantity=item.quantity, + sell_price=item.sell_price, + cost_basis_per_unit=item.cost_basis_per_unit, + fee_amount=item.fee_amount, + tax_amount=item.tax_amount, + realized_pnl=item.realized_pnl, + order_id=item.order_id, + execution_id=item.execution_id, + ) + for item in result.items + ] + + return RealizedPnlResponse( + total_realized_pnl=result.total_realized_pnl, + items=pnl_items, + cost_basis_from=result.cost_basis_from, + data_completeness_warning=result.data_completeness_warning, + ) diff --git a/app/domain/pnl.py b/app/domain/pnl.py new file mode 100644 index 0000000..27d7b4b --- /dev/null +++ b/app/domain/pnl.py @@ -0,0 +1,179 @@ +"""WAC (weighted average cost) based realized PnL calculation (REQ-041). + +This module contains pure Python domain logic with no repository or DB dependency. +All inputs are plain dicts or dataclasses; no database driver imports are used here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass +class PnlItem: + """Realized PnL for a single sell trade.""" + + symbol: str | None + executed_at: datetime + quantity: float + sell_price: float + cost_basis_per_unit: float + fee_amount: float + tax_amount: float + realized_pnl: float + order_id: str | None = None + execution_id: str | None = None + + +@dataclass +class WacPnlResult: + """Result of WAC realized PnL calculation over a set of ledger rows.""" + + items: list[PnlItem] = field(default_factory=list) + total_realized_pnl: float = 0.0 + cost_basis_from: datetime | None = None + data_completeness_warning: str | None = None + + +def calculate_realized_pnl( + rows: list[dict[str, Any]], + *, + from_dt: datetime | None = None, + to_dt: datetime | None = None, + symbol: str | None = None, +) -> WacPnlResult: + """Calculate WAC-based realized PnL from a time-ordered list of ledger rows. + + Args: + rows: List of portfolio_ledger row dicts, each containing: + side (str), quantity (float), price (float), + fee_amount (float), tax_amount (float), + executed_at (datetime), order_id (str|None), + execution_id (str|None), symbol (str|None, optional). + Must be sorted by executed_at ASC. + from_dt: Inclusive lower bound for sell trade date filter (UTC datetime). + If None, no lower bound is applied. + to_dt: Inclusive upper bound for sell trade date filter (UTC datetime). + If None, no upper bound is applied. + symbol: Optional symbol filter for sell trades. Ignored if None. + + Returns: + WacPnlResult with per-sell PnL items, total, cost_basis_from, + and data_completeness_warning when history is incomplete. + + WAC Rules (REQ-041): + buy: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty) + running_qty += qty + sell: realized_pnl = (sell_price - running_wac) * qty - fee - tax + running_qty -= qty + Incomplete history (running_qty < sell_qty or sell with running_qty=0): + data_completeness_warning is set; calculation continues with best estimate. + """ + if not rows: + return WacPnlResult() + + running_wac: float = 0.0 + running_qty: float = 0.0 + cost_basis_from: datetime | None = None + warning_triggered: bool = False + items: list[PnlItem] = [] + total_pnl: float = 0.0 + + for row in rows: + side = str(row.get("side", "")).lower() + qty = float(row.get("quantity", 0.0)) + price = float(row.get("price", 0.0)) + fee = float(row.get("fee_amount", 0.0)) + tax = float(row.get("tax_amount", 0.0)) + executed_at: datetime | None = row.get("executed_at") + row_symbol: str | None = row.get("symbol") + order_id: str | None = row.get("order_id") + execution_id: str | None = row.get("execution_id") + + if side == "buy": + # Update running WAC + new_total_qty = running_qty + qty + if new_total_qty > 0: + running_wac = (running_wac * running_qty + price * qty) / new_total_qty + running_qty = new_total_qty + # Track earliest buy for cost_basis_from + if cost_basis_from is None and executed_at is not None: + cost_basis_from = executed_at + + elif side == "sell": + # Check for incomplete history + if running_qty <= 0: + warning_triggered = True + elif qty > running_qty + 1e-9: + warning_triggered = True + + # Apply date filter for sell trades (all buy rows participate in WAC regardless) + in_range = True + if from_dt is not None and executed_at is not None: + # Make both timezone-aware or both naive for comparison + exec_dt = _normalize_tz(executed_at, from_dt) + if exec_dt < from_dt: + in_range = False + if to_dt is not None and executed_at is not None and in_range: + exec_dt_to = _normalize_tz(executed_at, to_dt) + if exec_dt_to > to_dt: + in_range = False + + # Apply symbol filter + if symbol is not None and row_symbol is not None and row_symbol != symbol: + in_range = False + + if in_range: + realized = (price - running_wac) * qty - fee - tax + total_pnl += realized + items.append( + PnlItem( + symbol=row_symbol, + executed_at=executed_at, # type: ignore[arg-type] + quantity=qty, + sell_price=price, + cost_basis_per_unit=running_wac, + fee_amount=fee, + tax_amount=tax, + realized_pnl=realized, + order_id=order_id, + execution_id=execution_id, + ) + ) + + # Update running qty regardless of date filter (WAC state must be correct) + running_qty -= qty + if running_qty < 0: + running_qty = 0.0 + + warning: str | None = None + if warning_triggered: + warning = ( + "이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 " + "cost_basis 산출이 부정확할 수 있습니다. " + "portfolio_ledger 이력이 포트폴리오 생성 초기부터 완전한지 확인하세요." + ) + + return WacPnlResult( + items=items, + total_realized_pnl=total_pnl, + cost_basis_from=cost_basis_from, + data_completeness_warning=warning, + ) + + +def _normalize_tz(dt: datetime, reference: datetime) -> datetime: + """Align timezone-awareness of dt to match reference. + + If reference is timezone-aware and dt is naive, treat dt as UTC. + If reference is naive and dt is aware, strip tzinfo. + """ + import datetime as _dt_module + + if reference.tzinfo is not None and dt.tzinfo is None: + return dt.replace(tzinfo=_dt_module.timezone.utc) + if reference.tzinfo is None and dt.tzinfo is not None: + return dt.replace(tzinfo=None) + return dt diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 75a7c51..8bee6ab 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -250,6 +250,22 @@ class PortfolioRepository(Protocol): def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ... + def list_trades_by_date( + self, + portfolio_id: str, + *, + date: datetime | None = None, + from_: datetime | None = None, + to_: datetime | None = None, + ) -> list[LedgerEntry]: ... + + def get_all_trades_for_pnl( + self, + portfolio_id: str, + *, + symbol: str | None = None, + ) -> list[LedgerEntry]: ... + def insert_ledger_trade( self, *, diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 00e1911..7f86af3 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1337,6 +1337,102 @@ class PostgresPortfolioRepository: rows = cur.fetchall() return [self._row_to_ledger_entry(row) for row in rows] + def list_trades_by_date( + self, + portfolio_id: str, + *, + date: "datetime | None" = None, + from_: "datetime | None" = None, + to_: "datetime | None" = None, + ) -> list[LedgerEntry]: + """Return ledger entries for portfolio_id filtered by date range (REQ-039). + + Either `date` (single day) or `from_`/`to_` (range, both inclusive) must be + provided. The caller is responsible for converting date strings to datetime + boundaries (start of day / start of next day). + """ + self.ensure_schema() + clauses = ["portfolio_id = %s"] + params: list[object] = [portfolio_id] + + if date is not None: + # Single day: [date 00:00:00, date+1day 00:00:00) + clauses.append("executed_at >= %s") + params.append(date) + import datetime as _dt + next_day = date + _dt.timedelta(days=1) + clauses.append("executed_at < %s") + params.append(next_day) + else: + if from_ is not None: + clauses.append("executed_at >= %s") + params.append(from_) + if to_ is not None: + import datetime as _dt + # to_ is inclusive — extend to end of that day + next_day = to_ + _dt.timedelta(days=1) + clauses.append("executed_at < %s") + params.append(next_day) + + where = "WHERE " + " AND ".join(clauses) + with self._connect().cursor() as cur: + cur.execute( + f""" + SELECT + ledger_entry_id, portfolio_id, order_id, execution_id, side, + quantity::float8, price::float8, + fee_amount::float8, tax_amount::float8, + executed_at, recorded_at, + cash_balance_after::float8, asset_quantity_after::float8, + total_value_after::float8, profit_buffer_after::float8, + strategy_set_id, note + FROM portfolio_ledger + {where} + ORDER BY executed_at ASC, ledger_entry_id ASC + """, + tuple(params), + ) + rows = cur.fetchall() + return [self._row_to_ledger_entry(row) for row in rows] + + def get_all_trades_for_pnl( + self, + portfolio_id: str, + *, + symbol: str | None = None, + ) -> list[LedgerEntry]: + """Return all buy/sell ledger entries for portfolio_id for WAC PnL calculation (REQ-040/041). + + Returns ALL buy/sell history (not bounded by from_/to_) so that the WAC + calculation is accurate. The caller (domain layer) applies the date filter + to sell events when collecting realized PnL items. + + portfolio_ledger does not store symbol per row — symbol filtering is handled + at the portfolio level (one portfolio = one symbol). The `symbol` parameter + is forwarded to the domain layer via the returned rows; it is not used in SQL. + """ + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT + ledger_entry_id, portfolio_id, order_id, execution_id, side, + quantity::float8, price::float8, + fee_amount::float8, tax_amount::float8, + executed_at, recorded_at, + cash_balance_after::float8, asset_quantity_after::float8, + total_value_after::float8, profit_buffer_after::float8, + strategy_set_id, note + FROM portfolio_ledger + WHERE portfolio_id = %s + AND side IN ('buy', 'sell') + ORDER BY executed_at ASC, ledger_entry_id ASC + """, + (portfolio_id,), + ) + rows = cur.fetchall() + return [self._row_to_ledger_entry(row) for row in rows] + def insert_ledger_trade( self, *, diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 8dabd88..a975469 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from typing import Any, Literal @@ -249,3 +249,50 @@ class AssetCatalogResponse(BaseModel): class AssetCatalogListResponse(BaseModel): items: list[AssetCatalogResponse] total: int + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{id}/trades (REQ-039) +# --------------------------------------------------------------------------- + +class TradeHistoryItem(BaseModel): + ledger_entry_id: int + portfolio_id: str + order_id: str | None = None + execution_id: str | None = None + side: str + quantity: float + price: float + fee_amount: float + tax_amount: float + executed_at: datetime + recorded_at: datetime | None = None + + +class TradeHistoryResponse(BaseModel): + items: list[TradeHistoryItem] + total: int + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{id}/pnl/realized (REQ-040) +# --------------------------------------------------------------------------- + +class RealizedPnlItem(BaseModel): + symbol: str | None = None + executed_at: datetime | None = None + quantity: float + sell_price: float + cost_basis_per_unit: float + fee_amount: float + tax_amount: float + realized_pnl: float + order_id: str | None = None + execution_id: str | None = None + + +class RealizedPnlResponse(BaseModel): + total_realized_pnl: float + items: list[RealizedPnlItem] + cost_basis_from: datetime | None = None + data_completeness_warning: str | None = None diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md new file mode 100644 index 0000000..ec4dbd4 --- /dev/null +++ b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md @@ -0,0 +1,133 @@ +# BAT-017 Design + +## Design Gate 결정 + +- Design Gate: 통과 (simple, 기존 패턴 재사용, M1 fast-track) +- design.md는 계층 경계와 인터페이스 계약을 명시한다. + +## 계층 경계 + +``` +API 계층 (app/api/v1_portfolios.py) + └─ require_read 인증 + _get_portfolio_or_404 소유권 확인 + └─ 날짜 파라미터 파싱/검증 (Query) + └─ Repository 위임 (list_trades_by_date / get_all_trades_for_pnl) + └─ Domain 위임 (calculate_realized_pnl) + +Domain 계층 (app/domain/pnl.py) + └─ calculate_realized_pnl(rows: list[dict]) → WacPnlResult + └─ 순수 Python, psycopg/DB 무의존 + └─ WAC 누적 계산 + data_completeness_warning 판단 + +Repository 계층 (app/repositories/postgres.py) + └─ list_trades_by_date(portfolio_id, date, from_, to_) → list[LedgerEntry] + └─ get_all_trades_for_pnl(portfolio_id, from_, to_, symbol) → list[LedgerEntry] + - from_/to_는 매도 집계 기간 + - buy 이력은 집계 기간 이전 전체 조회(WAC 정확성) + +Schema 계층 (app/schemas/v1.py) + └─ TradeHistoryItem, TradeHistoryResponse + └─ RealizedPnlItem, RealizedPnlResponse + - data_completeness_warning: str | None + - cost_basis_from: date | None +``` + +## WAC 계산 함수 인터페이스 + +```python +# app/domain/pnl.py + +@dataclass +class PnlItem: + symbol: str | None + executed_at: datetime + quantity: float + sell_price: float + cost_basis_per_unit: float + fee_amount: float + tax_amount: float + realized_pnl: float + +@dataclass +class WacPnlResult: + items: list[PnlItem] + total_realized_pnl: float + cost_basis_from: datetime | None + data_completeness_warning: str | None + +def calculate_realized_pnl(rows: list[dict]) -> WacPnlResult: + """ + 입력: portfolio_ledger 행 목록 (시간 순 정렬) + 각 dict: side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id + 출력: WacPnlResult + """ +``` + +## HTTP API 계약 + +### GET /v1/portfolios/{portfolio_id}/trades + +- 인증: `require_read` (read_token 이상) +- 쿼리 파라미터: + - `date` (str, optional): YYYY-MM-DD — 단일 날짜 (from=to로 처리) + - `from` (str, optional): YYYY-MM-DD — 범위 시작 (경계 포함) + - `to` (str, optional): YYYY-MM-DD — 범위 종료 (경계 포함) + - `date`와 `from`/`to` 중 하나라도 없으면 422 +- 응답: `TradeHistoryResponse` + +### GET /v1/portfolios/{portfolio_id}/pnl/realized + +- 인증: `require_read` (read_token 이상) +- 쿼리 파라미터: + - `from` (str, required): YYYY-MM-DD + - `to` (str, required): YYYY-MM-DD + - `symbol` (str, optional): 종목 필터 +- 응답: `RealizedPnlResponse` + +## Repository 쿼리 설계 + +### list_trades_by_date +```sql +SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, + quantity::float8, price::float8, fee_amount::float8, tax_amount::float8, + executed_at, recorded_at, cash_balance_after::float8, asset_quantity_after::float8, + total_value_after::float8, profit_buffer_after::float8, strategy_set_id, note +FROM portfolio_ledger +WHERE portfolio_id = %s + AND executed_at >= %s -- from_ (date boundary: 00:00:00 UTC) + AND executed_at < %s -- to_ + 1 day (exclusive upper bound) +ORDER BY executed_at ASC, ledger_entry_id ASC +``` + +### get_all_trades_for_pnl +```sql +-- WAC 정확성을 위해 집계 기간 이전 buy 이력도 포함 +-- symbol JOIN은 asset 테이블을 통해 수행하지 않고, +-- portfolio_ledger에 symbol 컬럼이 없으므로 portfolio의 symbol을 통해 필터 +-- (portfolio 1개 = 1 종목이므로 symbol 파라미터는 portfolio 단위 필터) +SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, + quantity::float8, price::float8, fee_amount::float8, tax_amount::float8, + executed_at, recorded_at, cash_balance_after::float8, asset_quantity_after::float8, + total_value_after::float8, profit_buffer_after::float8, strategy_set_id, note +FROM portfolio_ledger +WHERE portfolio_id = %s + AND side IN ('buy', 'sell') +ORDER BY executed_at ASC, ledger_entry_id ASC +-- 매도 집계는 Python 계층(WAC 함수)에서 from_/to_ 기간 필터 적용 +``` + +## 날짜 파라미터 파싱 규칙 + +- `date` 파라미터가 있으면 from_=date, to_=date (하루 범위) +- `from` + `to` 파라미터가 있으면 그대로 사용 +- `date`도 없고 `from`/`to`도 없으면 HTTP 422 반환 +- `from`만 있거나 `to`만 있는 경우: 422 반환 +- 날짜 형식 파싱 실패 시: HTTP 422 반환 (YYYY-MM-DD 아닌 경우) + +## data_completeness_warning 트리거 + +REQ-041 AC-3/AC-4 기준: +1. running_qty=0인 상태에서 sell 발생 (buy 이력 없음) +2. sell 수량이 running_qty를 초과 (running_qty < sell_qty) + +경고 시 계산은 중단하지 않고 최선 추정 계속 (WAC=0 또는 현재 WAC 유지). diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md new file mode 100644 index 0000000..cecb2cf --- /dev/null +++ b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/implementation.md @@ -0,0 +1,60 @@ +# BAT-017 Implementation + +## Status: Complete + +## Changed Files + +### New Files + +| File | Purpose | +| --- | --- | +| `app/domain/pnl.py` | WAC realized PnL calculation (REQ-041) — pure Python, no DB dependency | +| `tests/test_wac_calculation.py` | WAC unit tests — 20 tests, DB-free (REQ-042) | +| `tests/test_v1_portfolios_trades.py` | GET /trades integration tests — 12 tests (REQ-042) | +| `tests/test_v1_portfolios_pnl.py` | GET /pnl/realized integration tests — 18 tests (REQ-042) | + +### Modified Files + +| File | Changes | +| --- | --- | +| `app/schemas/v1.py` | Added `date` import; added `TradeHistoryItem`, `TradeHistoryResponse`, `RealizedPnlItem`, `RealizedPnlResponse` | +| `app/api/v1_portfolios.py` | Added `date`, `datetime`, `timedelta`, `timezone` imports; added `calculate_realized_pnl` import; added schema imports; added `_parse_date_param()` helper; added `list_portfolio_trades()` endpoint (REQ-039); added `get_realized_pnl()` endpoint (REQ-040) | +| `app/repositories/postgres.py` | Added `list_trades_by_date()` method (REQ-039); added `get_all_trades_for_pnl()` method (REQ-040/041) | +| `app/repositories/portfolio_repository.py` | Added `list_trades_by_date()` and `get_all_trades_for_pnl()` abstract method signatures | +| `docs/batches/index.md` | Added BAT-017 register row | + +### Batch Documents + +| File | Purpose | +| --- | --- | +| `docs/batches/bat-017_.../index.md` | Batch index | +| `docs/batches/bat-017_.../planning.md` | Planning | +| `docs/batches/bat-017_.../design.md` | Design | +| `docs/batches/bat-017_.../implementation.md` | This file | +| `docs/batches/bat-017_.../verification.md` | Verification | + +## Key Implementation Decisions + +### WAC 계산 함수 위치 +- `app/domain/pnl.py` 신규 — domain 계층 (REQ-041 Notes: domain 계층 권장) +- `app/services/portfolio_service.py` 수정 없음 — WAC 함수는 domain에 독립 배치 + +### `_parse_date_param` 날짜 형식 강제 +- Python 3.11+ `date.fromisoformat()`은 `YYYYMMDD`(대시 없음)도 수용함 +- REQ-039 AC-4 준수를 위해 `re.fullmatch(r"\d{4}-\d{2}-\d{2}", value)` 사전 검증 추가 + +### `get_all_trades_for_pnl` 쿼리 설계 +- WAC 정확성 확보를 위해 집계 기간(`from`/`to`) 제약 없이 portfolio의 모든 buy/sell 이력 조회 +- Python domain 함수에서 `from_dt`/`to_dt` 파라미터로 sell 집계 필터 적용 + +### `list_trades_by_date` 날짜 경계 +- `date` 단일: `[date 00:00 UTC, date+1day 00:00 UTC)` 반개구간 +- `to_` 포함 경계: `to_ + timedelta(days=1)` exclusive upper bound 적용 + +### data_completeness_warning 경고 +- running_qty=0에서 sell 발생 또는 sell qty > running_qty 시 경고 필드 설정 +- 계산은 중단하지 않고 최선 추정 계속 (REQ-041 AC-3/AC-4) + +### 서비스/클라이언트 토큰 접근 제한 +- 두 엔드포인트 모두 `token_type in ("service", "client")` → 403 +- DB 토큰 또는 env 토큰(read 이상)만 접근 가능 diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md new file mode 100644 index 0000000..3513447 --- /dev/null +++ b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/index.md @@ -0,0 +1,42 @@ +# BAT-017: 일일 거래내역 + 실현 PnL 조회 API (dcy-007 M1 fast-track) + +- Status: in-delivery +- Profile: standard +- Source Discovery: dcy-007 +- Included REQ: REQ-039, REQ-040, REQ-041, REQ-042 +- Excluded REQ: 없음 + +## Summary +- dcy-007(일일 거래내역 + 실현 PnL 조회 API) 산출물 4건을 단일 batch로 묶는다. +- GET /v1/portfolios/{id}/trades (날짜/범위 필터) 엔드포인트를 신설하고(REQ-039), + GET /v1/portfolios/{id}/pnl/realized (WAC 기반 실현 PnL 집계) 엔드포인트를 신설한다(REQ-040). +- WAC(weighted average cost) cost_basis 산출 로직을 service/domain 계층에 순수 Python으로 격리 구현해 단위 테스트가 DB 없이 가능하도록 한다(REQ-041). +- AC 기댓값 수기 대조·날짜 필터·422·R-1 경고 필드 테스트 커버리지를 확보한다(REQ-042). + +## Scope +- In Scope: + - REQ-039 (Interface): GET /v1/portfolios/{id}/trades (date 단일/from+to 범위 필터, read_token 이상 인증, 소유권 403, 404, 날짜누락 422) + - REQ-040 (Interface): GET /v1/portfolios/{id}/pnl/realized (from+to 필수, symbol 선택, WAC 기반 실현 PnL, data_completeness_warning, cost_basis_from) + - REQ-041 (Integration): WAC cost_basis 산출 로직 — app/domain/pnl.py 신규(순수 Python, DB 무의존, R-1 완화 경고 로직 포함) + - REQ-042 (Testing): tests/test_wac_calculation.py, tests/test_v1_portfolios_trades.py, tests/test_v1_portfolios_pnl.py 신규 + - app/repositories/postgres.py: list_trades_by_date, get_all_trades_for_pnl 메서드 추가 + - app/repositories/portfolio_repository.py: abstract method 추가 + - app/schemas/v1.py: TradeHistoryItem, TradeHistoryResponse, RealizedPnlItem, RealizedPnlResponse 스키마 추가 +- Out of Scope: + - 미실현 PnL(현재가 연동) — dcy-007 OQ-2 Out of Scope + - 전체 portfolio 집계 PnL 뷰 — dcy-007 OQ-2 Out of Scope + - DB 마이그레이션 — 기존 portfolio_ledger 스키마로 조회 가능 + - 페이지네이션 — 이번 범위 외 이연 + - 성능 테스트(p95 500ms) — 통합/성능 테스트 환경에서 별도 검증 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- 6/22 적용 M1 fast-track — 단일 batch, 단일 구현 주기. +- DB 마이그레이션 불필요(기존 portfolio_ledger 스키마). +- 구현 순서: REQ-041(WAC 도메인 로직) → REQ-039/040(API 계층) → REQ-042(테스트). +- WAC 기댓값 199,300 검증: buy 100주@10,000 + buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200. diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md new file mode 100644 index 0000000..cfa3211 --- /dev/null +++ b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/planning.md @@ -0,0 +1,64 @@ +# BAT-017 Planning + +## Included REQ + +| REQ | Type | Summary | +| --- | --- | --- | +| REQ-039 | Interface | GET /v1/portfolios/{id}/trades — 날짜/범위 필터 체결 내역 | +| REQ-040 | Interface | GET /v1/portfolios/{id}/pnl/realized — WAC 실현 PnL 집계 | +| REQ-041 | Integration | WAC cost_basis 산출 로직 (service/domain 격리) | +| REQ-042 | Testing | 테스트 커버리지 (WAC 기댓값 대조, 날짜 필터, 422, R-1 경고) | + +## Delivery Plan + +### 구현 순서 +1. **REQ-041 먼저** — WAC 도메인 로직(`app/domain/pnl.py`) 신규. 순수 Python, DB 무의존. REQ-040 API가 이에 의존하므로 선행 필수. +2. **REQ-039** — GET /trades 엔드포인트 + repository 메서드(`list_trades_by_date`) + 스키마. +3. **REQ-040** — GET /pnl/realized 엔드포인트 + repository 메서드(`get_all_trades_for_pnl`) + 스키마. +4. **REQ-042** — 테스트 파일 3종 신규 작성. + +### 파일 변경 목록 +- **신규 생성**: + - `app/domain/pnl.py` — WAC 계산 함수 (REQ-041) + - `tests/test_wac_calculation.py` — WAC 순수 단위 테스트 (REQ-042) + - `tests/test_v1_portfolios_trades.py` — GET /trades 통합 테스트 (REQ-042) + - `tests/test_v1_portfolios_pnl.py` — GET /pnl/realized 통합 테스트 (REQ-042) +- **수정**: + - `app/schemas/v1.py` — TradeHistoryItem, TradeHistoryResponse, RealizedPnlItem, RealizedPnlResponse 추가 + - `app/api/v1_portfolios.py` — GET /trades, GET /pnl/realized 엔드포인트 추가 + - `app/repositories/postgres.py` — list_trades_by_date, get_all_trades_for_pnl 메서드 추가 + - `app/repositories/portfolio_repository.py` — abstract method 추가 + - `docs/batches/index.md` — BAT-017 register 행 추가 + +## Dependencies + +- 기존 portfolio_ledger 테이블(TimescaleDB hypertable) — 스키마 변경 없음 +- idx_portfolio_ledger_portfolio_executed 인덱스 — 날짜 범위 쿼리 활용 +- 기존 require_read 인증 의존성 — 변경 없음 +- REQ-041 WAC 로직은 REQ-040 엔드포인트의 service 계층 호출 대상 + +## Design Gate + +**Design 필요: YES** + +근거: 신규 엔드포인트 2개(HTTP 계약 확장), 신규 스키마 4종, 신규 domain 계층 모듈. 인터페이스 계약 및 계층 경계를 design.md에 명시해야 한다. + +그러나 M1 fast-track 지시로 planning → design → implementation을 연속 수행한다. 설계는 simple하고 기존 코드 패턴을 그대로 따른다: +- WAC 함수: 순수 Python 함수, 입/출력 타입 명확, DB 무의존 +- 두 엔드포인트: 기존 `_get_portfolio_or_404` + `require_read` 패턴 재사용 +- Repository: 기존 `list_recent_trades` 패턴 확장 + +## Milestones + +1. app/domain/pnl.py + test_wac_calculation.py 구현 및 단위 테스트 통과 +2. GET /trades 엔드포인트 + 통합 테스트 구현 +3. GET /pnl/realized 엔드포인트 + 통합 테스트 구현 +4. 전체 pytest 실행 — 신규 3개 파일 + 기존 회귀 확인 + +## Risks + +| Risk | 완화 | +| --- | --- | +| repository 메서드가 WAC 산출용 집계기간 이전 buy도 조회해야 함 | get_all_trades_for_pnl은 집계 기간과 무관하게 해당 portfolio+symbol의 전체 이력을 조회해 WAC 정확성 확보 | +| TestClient 통합 테스트가 DB 의존 | MagicMock 기반 repo 주입 패턴(기존 test_v1_portfolios_ledger.py 참조)으로 DB 없이 실행 | +| R-1 이력 불완전 경고 조건 | running_qty < sell_qty 또는 running_qty=0에서 sell 발생 시 data_completeness_warning 반환 (REQ-041 AC-3/AC-4) | diff --git a/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md new file mode 100644 index 0000000..fdf962c --- /dev/null +++ b/docs/batches/bat-017_20260618_trade-history-and-pnl-api/verification.md @@ -0,0 +1,166 @@ +# BAT-017 Verification + +## Status: approved (confirm-batch-verification) + +## 실행 명령 + +```bash +.venv/bin/python3 -m pytest tests/test_wac_calculation.py tests/test_v1_portfolios_trades.py tests/test_v1_portfolios_pnl.py -v +``` + +## 실제 pytest 결과 (2026-06-18) + +``` +================================================================================== test session starts ================================================================================== +platform linux -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0 +rootdir: /home/genichin/workspace/trex-lead/repos/portfolio-hub +collected 50 items + +tests/test_wac_calculation.py::TestWac199300::test_total_realized_pnl PASSED +tests/test_wac_calculation.py::TestWac199300::test_cost_basis_per_unit PASSED +tests/test_wac_calculation.py::TestWac199300::test_no_warning_when_history_complete PASSED +tests/test_wac_calculation.py::TestWac199300::test_cost_basis_from_is_first_buy PASSED +tests/test_wac_calculation.py::TestWac199300::test_sell_item_fields PASSED +tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_empty_items PASSED +tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_cost_basis_from_set PASSED +tests/test_wac_calculation.py::TestBuyOnlyNoSell::test_no_warning PASSED +tests/test_wac_calculation.py::TestEmptyInput::test_empty_rows PASSED +tests/test_wac_calculation.py::TestSellWithNoBuyHistory::test_warning_is_set PASSED +tests/test_wac_calculation.py::TestSellWithNoBuyHistory::test_calculation_continues PASSED +tests/test_wac_calculation.py::TestSellExceedsRunningQty::test_warning_when_oversell PASSED +tests/test_wac_calculation.py::TestSellExceedsRunningQty::test_calculation_not_aborted PASSED +tests/test_wac_calculation.py::TestMultipleBuySellCycles::test_wac_is_cumulative PASSED +tests/test_wac_calculation.py::TestDateFilter::test_filter_includes_only_sells_in_range PASSED +tests/test_wac_calculation.py::TestDateFilter::test_filter_both_sells_in_range PASSED +tests/test_wac_calculation.py::TestDateFilter::test_wac_uses_all_buys_regardless_of_filter PASSED +tests/test_wac_calculation.py::TestSymbolFilter::test_symbol_filter_excludes_other_symbols PASSED +tests/test_wac_calculation.py::TestSymbolFilter::test_no_symbol_filter_includes_all PASSED +tests/test_wac_calculation.py::TestNoPsycopgInDomainModule::test_pnl_module_has_no_db_import PASSED +tests/test_v1_portfolios_trades.py::test_trades_single_date_returns_matching_count PASSED +tests/test_v1_portfolios_trades.py::test_trades_single_date_item_fields PASSED +tests/test_v1_portfolios_trades.py::test_trades_range_filter_returns_items PASSED +tests/test_v1_portfolios_trades.py::test_trades_no_date_params_returns_422 PASSED +tests/test_v1_portfolios_trades.py::test_trades_bad_date_format_returns_422 PASSED +tests/test_v1_portfolios_trades.py::test_trades_slash_date_format_returns_422 PASSED +tests/test_v1_portfolios_trades.py::test_trades_empty_range_returns_200_empty PASSED +tests/test_v1_portfolios_trades.py::test_trades_non_owner_returns_403 PASSED +tests/test_v1_portfolios_trades.py::test_trades_nonexistent_portfolio_returns_404 PASSED +tests/test_v1_portfolios_trades.py::test_trades_endpoint_in_openapi PASSED +tests/test_v1_portfolios_trades.py::test_trades_from_only_without_to_returns_422 PASSED +tests/test_v1_portfolios_trades.py::test_trades_to_only_without_from_returns_422 PASSED +tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_total_realized_pnl_is_199300 PASSED +tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_cost_basis_per_unit_is_11000 PASSED +tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_items_contain_required_fields PASSED +tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_data_completeness_warning_is_null_when_complete PASSED +tests/test_v1_portfolios_pnl.py::TestWac199300EndToEnd::test_cost_basis_from_is_first_buy_date PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_missing_from_returns_422 PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_missing_to_returns_422 PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_both_missing_returns_422 PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_no_sells_returns_200_with_zero PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_empty_ledger_returns_200_with_zero PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_sell_without_buy_triggers_warning PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_complete_history_warning_is_null PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_oversell_triggers_warning PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_symbol_filter_passed_to_repo PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_symbol_filter_excludes_other_symbol_sells PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_non_owner_returns_403 PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_nonexistent_portfolio_returns_404 PASSED +tests/test_v1_portfolios_pnl.py::test_pnl_endpoint_in_openapi PASSED + +50 passed, 8 warnings in 3.43s +``` + +**결과: 50 passed, 0 failed** + +## 기존 회귀 테스트 결과 + +```bash +.venv/bin/python3 -m pytest \ + --ignore=tests/test_wac_calculation.py \ + --ignore=tests/test_v1_portfolios_trades.py \ + --ignore=tests/test_v1_portfolios_pnl.py +``` + +``` +377 passed, 3 warnings in 17.25s +``` + +**결과: 377 passed, 0 failed — 기존 테스트 회귀 없음** + +## 환경 제약 사항 + +- DB 통합 테스트 환경(`pfhub_DB_ENABLED=true`, TimescaleDB 컨테이너) 없이 실행. +- 통합 테스트(`test_v1_portfolios_trades.py`, `test_v1_portfolios_pnl.py`)는 TestClient + MagicMock repo 주입 방식으로 DB 없이 실행 가능. +- WAC 단위 테스트(`test_wac_calculation.py`)는 순수 Python — DB 불필요 확인. +- 성능 테스트(AC-9: p95 500ms) 및 DB 인덱스 활용 검증은 통합/성능 테스트 환경에서 별도 수행 필요. + +## REQ별 AC 충족 매핑 + +### REQ-039: GET /v1/portfolios/{id}/trades + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: date 필터 + 200 + 항목 필드 확인 | `test_trades_single_date_returns_matching_count`, `test_trades_single_date_item_fields` | PASS | +| AC-2: from/to 범위 필터 + 200 + 경계 포함 | `test_trades_range_filter_returns_items` | PASS | +| AC-3: 날짜 파라미터 누락 → 422 | `test_trades_no_date_params_returns_422` | PASS | +| AC-4: 날짜 형식 오류 → 422 | `test_trades_bad_date_format_returns_422`, `test_trades_slash_date_format_returns_422` | PASS | +| AC-5: 0건 → 200 + empty | `test_trades_empty_range_returns_200_empty` | PASS | +| AC-6: 비소유자 → 403 | `test_trades_non_owner_returns_403` | PASS | +| AC-7: 존재하지 않는 portfolio → 404 | `test_trades_nonexistent_portfolio_returns_404` | PASS | +| AC-8: OpenAPI 문서화 | `test_trades_endpoint_in_openapi` | PASS | +| AC-9: p95 500ms | 성능 환경 별도 (환경 제약) | 환경 제약 | + +### REQ-040: GET /v1/portfolios/{id}/pnl/realized + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: 199,300 WAC + cost_basis_per_unit=11,000 | `TestWac199300EndToEnd::test_total_realized_pnl_is_199300`, `test_cost_basis_per_unit_is_11000` | PASS | +| AC-2: symbol 필터 | `test_pnl_symbol_filter_passed_to_repo`, `test_pnl_symbol_filter_excludes_other_symbol_sells` | PASS | +| AC-3: from/to 누락 → 422 | `test_pnl_missing_from_returns_422`, `test_pnl_missing_to_returns_422`, `test_pnl_both_missing_returns_422` | PASS | +| AC-4: 매도 0건 → 200 + total=0 + items=[] | `test_pnl_no_sells_returns_200_with_zero`, `test_pnl_empty_ledger_returns_200_with_zero` | PASS | +| AC-5: 이력 불완전 → data_completeness_warning 비어 있지 않음 | `test_pnl_sell_without_buy_triggers_warning`, `test_pnl_oversell_triggers_warning` | PASS | +| AC-6: 이력 완전 → data_completeness_warning null | `test_pnl_complete_history_warning_is_null`, `TestWac199300EndToEnd::test_data_completeness_warning_is_null_when_complete` | PASS | +| AC-7: 비소유자 → 403 | `test_pnl_non_owner_returns_403` | PASS | +| AC-8: 존재하지 않는 portfolio → 404 | `test_pnl_nonexistent_portfolio_returns_404` | PASS | +| AC-9: OpenAPI 문서화 (data_completeness_warning 포함) | `test_pnl_endpoint_in_openapi` | PASS | +| AC-10: p95 500ms | 성능 환경 별도 (환경 제약) | 환경 제약 | + +### REQ-041: WAC cost_basis 산출 로직 + +| AC | 테스트 | 결과 | +| --- | --- | --- | +| AC-1: WAC=11,000 + realized_pnl=199,300 | `TestWac199300::test_total_realized_pnl`, `test_cost_basis_per_unit` | PASS | +| AC-2: buy만 있고 sell 없음 → items=[] | `TestBuyOnlyNoSell::test_empty_items` | PASS | +| AC-3: sell 시작(buy 없음) → warning 비어 있지 않음 | `TestSellWithNoBuyHistory::test_warning_is_set`, `test_calculation_continues` | PASS | +| AC-4: sell qty > running_qty → warning | `TestSellExceedsRunningQty::test_warning_when_oversell` | PASS | +| AC-5: 반복 buy/sell → WAC 누적 정확성 | `TestMultipleBuySellCycles::test_wac_is_cumulative` | PASS | +| AC-6: 빈 입력 → items=[], total=0, cost_basis_from=None | `TestEmptyInput::test_empty_rows` | PASS | +| AC-7: DB import 없음 (grep) | `TestNoPsycopgInDomainModule::test_pnl_module_has_no_db_import` | PASS | +| AC-8: DB 없이 실행 | 전체 WAC 단위 테스트 DB 미사용 — PASS | PASS | + +### REQ-042: 테스트 커버리지 + +| AC | 결과 | +| --- | --- | +| AC-1: test_wac_calculation.py — WAC 시나리오 포함 199,300 | 20 tests PASS | +| AC-2: test_v1_portfolios_trades.py — date/from-to/422/403/404 | 12 tests PASS | +| AC-3: test_v1_portfolios_pnl.py — 199,300/422/0건/warning | 18 tests PASS | +| AC-4: 전체 50 tests PASSED | PASS | +| AC-5: WAC 단위 테스트 DB 없이 통과 | PASS | +| AC-6: 기존 377 tests 회귀 없음 | PASS | + +## 남은 한계 (환경 제약 및 OoS) + +1. **성능 테스트(AC-9/AC-10)**: p95 500ms — TimescaleDB 인덱스 환경에서 별도 실행 필요. 이번 batch 검증 범위 외. +2. **DB 직접 통합 테스트**: `pfhub_DB_ENABLED=true` + Docker 인프라 없이 실행. MagicMock 기반 단위/통합 테스트로 행동 검증 완료. +3. **symbol 컬럼 부재**: `portfolio_ledger`에 symbol 컬럼 없음. 현재 portfolio 단위(1 portfolio = 1 symbol) 가정. 멀티 symbol portfolio 지원 시 별도 설계 필요. +4. **DeprecationWarning**: `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT` (FastAPI 최신 버전 경고). 기존 코드 패턴 유지(breaking change 없음). 별도 cleanup batch에서 처리 권장. + +## Approval + +- Status: **approved** (confirm-batch-verification) → batch `release-candidate` +- Approver: 팀장 (Claude), 2026-06-18 +- Independent QC: `qc-reviewer` verdict **PASS-WITH-NOTES**. 50 passed 독립 재현 + 전체 427 passed 회귀 없음, WAC=11,000/realized=199,300 단위+e2e 진짜 검증, R-1 경고필드(buy없음·oversell·완전이력 null) 정상, NFR-3 격리(domain DB import 0) 확인, 스코프·DB 마이그레이션 무, 위생 이상 없음. +- 수용한 잔여(비차단 follow-up): (1) symbol 필터 `symbol_param != portfolio_symbol` edge 테스트 공백 — AC-2는 충족이나 완전 커버 아님. (2) `HTTP_422_UNPROCESSABLE_ENTITY` deprecated(기존 패턴). 둘 다 별도 cleanup batch 또는 후속 처리. +- 성능 AC-9/AC-10(p95 500ms)은 TimescaleDB 환경 필요 — 환경 제약 미검증(코드결함 아님). +- REQ-039~042: confirm-req-implemented로 `Implemented` 전환. diff --git a/docs/batches/index.md b/docs/batches/index.md index e868227..f14d5b1 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -24,3 +24,4 @@ | BAT-014 | batch-lite | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028, REQ-032 | dcy-003 | [bat-014](./bat-014_20260524_portfolioitem-symbol-response-contract/) | | BAT-015 | standard | release-candidate | REQ-033, REQ-034, REQ-035 | dcy-005 | [bat-015](./bat-015_20260601_rel-001-feedback-cleanup/) | | BAT-016 | standard | release-candidate | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) | +| BAT-017 | standard | release-candidate | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) | diff --git a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md b/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md index 119537e..e14c3a5 100644 --- a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md +++ b/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md @@ -1,6 +1,6 @@ # REQ-041: WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) -- Status: Approved +- Status: Implemented - Type: Integration - Priority: High - Owner: Justin Ko @@ -87,3 +87,10 @@ #### Change Summary - confirm-req: `Proposed` → `Approved`. WAC 공식 확정(FIFO 제외), NFR-3 격리 AC-7/AC-8·R-1 완화 AC-3/AC-4 확인, blocker 없음. 구현 위치(domain vs service)는 구현 단계 결정. M1 fast-track. + +### CHG-20260618-03 +- Date: 2026-06-18 +- Author: 팀장 (Claude) + +#### Change Summary +- confirm-req-implemented: `Approved` → `Implemented`. Evidence: app/domain/pnl.py 구현, WAC 누적 정확성+199,300 검증, NFR-3 격리(psycopg import 0) QC 확인, R-1 경고 동작, test_wac_calculation.py 20 passed. diff --git a/docs/srs/Interface/req-039_trade-history-query-endpoint.md b/docs/srs/Interface/req-039_trade-history-query-endpoint.md index 68a6bfa..2f95452 100644 --- a/docs/srs/Interface/req-039_trade-history-query-endpoint.md +++ b/docs/srs/Interface/req-039_trade-history-query-endpoint.md @@ -1,6 +1,6 @@ # REQ-039: GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -87,3 +87,10 @@ #### Change Summary - confirm-req: `Proposed` → `Approved`. dcy-007 FR-1/FR-4 정합, AC 구체적·test 가능, blocker 없음. `date` vs `from`/`to` 상호작용은 구현 단계 결정(AC-3 422 확정). M1 fast-track — REQ-040~042와 동일 batch. + +### CHG-20260618-03 +- Date: 2026-06-18 +- Author: 팀장 (Claude) + +#### Change Summary +- confirm-req-implemented: `Approved` → `Implemented`. Evidence: BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, test_v1_portfolios_trades.py 12 passed(AC-1~8), 성능 AC-9는 환경제약 미검증. metrics/DB 마이그레이션 무. diff --git a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md b/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md index d700287..04b4501 100644 --- a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md +++ b/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md @@ -1,6 +1,6 @@ # REQ-040: GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -92,3 +92,10 @@ #### Change Summary - confirm-req: `Proposed` → `Approved`. WAC 수치 AC-1(199,300)·R-1 경고 필드 AC-5/AC-6 확인, OQ-2 OoS 반영, blocker 없음. REQ-041 의존(동일 batch). M1 fast-track. + +### CHG-20260618-03 +- Date: 2026-06-18 +- Author: 팀장 (Claude) + +#### Change Summary +- confirm-req-implemented: `Approved` → `Implemented`. Evidence: BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, WAC 199,300 단위+e2e 검증, R-1 경고필드 정상, AC-1~9 PASS(AC-10 환경제약). 잔여(비차단): symbol 필터 edge 테스트 공백. diff --git a/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md b/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md index 63beab6..69e4933 100644 --- a/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md +++ b/docs/srs/Testing/req-042_trade-pnl-api-test-coverage.md @@ -1,6 +1,6 @@ # REQ-042: 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) -- Status: Approved +- Status: Implemented - Type: Testing - Priority: High - Owner: Justin Ko @@ -98,3 +98,10 @@ #### Change Summary - confirm-req: `Proposed` → `Approved`. WAC 시나리오 기댓값·날짜필터·422·R-1 경고 검증 AC가 REQ-039~041과 정합, blocker 없음. 구현 REQ와 동일 batch. M1 fast-track. + +### CHG-20260618-03 +- Date: 2026-06-18 +- Author: 팀장 (Claude) + +#### Change Summary +- confirm-req-implemented: `Approved` → `Implemented`. Evidence: 3개 테스트 파일 50 passed(20+12+18) 독립 QC 재현, 전체 427 passed 회귀 없음. AC-1~6 충족. diff --git a/docs/srs/index.md b/docs/srs/index.md index 2ac16bb..c507ff8 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -119,13 +119,14 @@ | REQ-036 | portfolio_ledger 회계 원장 통합 마이그레이션 (ledger_trades 흡수/DROP) | Migration | Implemented | High | Justin Ko | [req-036](./Migration/req-036_portfolio-ledger-consolidation-migration.md) | | REQ-037 | 신 path 회계 책임 단일화 + 응답에 portfolio 상태 포함 | Interface | Implemented | High | Justin Ko | [req-037](./Interface/req-037_ledger-trade-write-accounting-and-state-response.md) | | REQ-038 | 레거시 buy/sell endpoint deprecated 표기 | Interface | Implemented | Low | Justin Ko | [req-038](./Interface/req-038_legacy-buy-sell-endpoint-deprecation.md) | -| REQ-039 | GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 | Interface | Approved | High | Justin Ko | [req-039](./Interface/req-039_trade-history-query-endpoint.md) | -| REQ-040 | GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 | Interface | Approved | High | Justin Ko | [req-040](./Interface/req-040_realized-pnl-query-endpoint.md) | -| REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Approved | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) | -| REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Approved | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) | +| REQ-039 | GET /v1/portfolios/{id}/trades 체결 내역 조회 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-039](./Interface/req-039_trade-history-query-endpoint.md) | +| REQ-040 | GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-040](./Interface/req-040_realized-pnl-query-endpoint.md) | +| REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Implemented | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) | +| REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Implemented | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) | ## Recent Change Log Summary +- 2026-06-18: REQ-039/040/041/042 `Approved` → `Implemented` (BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, 50 passed/전체 427 회귀없음, WAC 199,300 검증). 잔여 비차단: symbol 필터 edge 테스트·HTTP_422 deprecated. - 2026-06-18: REQ-039/040/041/042 `Proposed` → `Approved` (confirm-req 일괄 승인, dcy-007 결정 정합·R-1 완화 반영·blocker 없음, M1 fast-track 동일 batch). - 2026-06-18: REQ-039/040/041/042 신규 (Proposed) — dcy-007(일일 거래내역 + 실현 PnL 조회 API) draft-req 산출물. REQ-039 체결 내역 조회(Interface), REQ-040 실현 PnL 집계(Interface), REQ-041 WAC cost_basis 산출 로직(Integration), REQ-042 테스트 커버리지(Testing). OQ-1=WAC(사장님 결정), OQ-2=Out of Scope. R-1 경고 필드(data_completeness_warning + cost_basis_from)가 REQ-040 AC-5/AC-6 및 REQ-042 AC-1/AC-3에 반영. - 2026-06-05: REQ-036/037/038 Approved → Implemented (CHG-20260605-04/04/03) — BAT-016 confirm-batch-verification 완료. 전체 376 passed(실 DB 통합 3건 포함). verify 단계에서 hypertable ON CONFLICT 불가 + 공유 connection close 결함 2건 발견·수정, REQ-036/037 CHG-20260605-03으로 idempotency를 DB partial unique → application-level(SELECT-before-insert under FOR UPDATE)로 재정의. BAT-016 release-candidate. REL-003 staging 이연: alembic fresh-DB 적용 + 정밀도/action 분포. diff --git a/tests/test_v1_portfolios_pnl.py b/tests/test_v1_portfolios_pnl.py new file mode 100644 index 0000000..476a87e --- /dev/null +++ b/tests/test_v1_portfolios_pnl.py @@ -0,0 +1,405 @@ +"""Tests for GET /v1/portfolios/{portfolio_id}/pnl/realized (REQ-040, REQ-042). + +Uses TestClient with MagicMock repo injection — no DB required. +WAC computation is exercised end-to-end through the HTTP layer. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal +from app.main import create_app + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +_USER_ID = "user-001" +_OTHER_USER_ID = "user-002" +_PORTFOLIO_ID = "pf-pnl-001" +_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/pnl/realized" +_DEFAULT_PARAMS = {"from": "2026-06-01", "to": "2026-06-30"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_settings() -> Settings: + get_settings.cache_clear() + return Settings( + db_enabled=True, + credential_encryption_key=_ENC_KEY, + allowed_origins_raw="http://localhost:3000", + ) + + +def _make_client(settings: Settings) -> TestClient: + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _make_portfolio(*, owner: str = _USER_ID, symbol: str = "005930") -> Portfolio: + return Portfolio( + portfolio_id=_PORTFOLIO_ID, + broker="kis", + status="enabled", + name="test-pnl-portfolio", + symbol=symbol, + asset_id="asset-001", + owner_user_id=owner, + cash_balance=1_000_000.0, + asset_quantity=0.0, + tag_ids=[], + managed_by_daemon_id=None, + ) + + +def _make_entry( + *, + ledger_entry_id: int, + side: str, + quantity: float, + price: float, + fee: float = 0.0, + tax: float = 0.0, + executed_at: datetime, +) -> LedgerEntry: + return LedgerEntry( + ledger_entry_id=ledger_entry_id, + portfolio_id=_PORTFOLIO_ID, + order_id=f"ord-{ledger_entry_id:03d}", + execution_id=f"exec-{ledger_entry_id:03d}", + side=side, + quantity=quantity, + price=price, + fee_amount=fee, + tax_amount=tax, + executed_at=executed_at, + recorded_at=executed_at, + ) + + +def _make_repo( + *, + portfolio: Portfolio | None = None, + trades: list[LedgerEntry] | None = None, + user_id: str = _USER_ID, + permission: str = "read", +) -> MagicMock: + repo = MagicMock() + principal = TokenPrincipal( + token_id="t1", + user_id=user_id, + permission=permission, + token_type="db", + runtime_id=None, + daemon_id=None, + ) + repo.get_token_principal.return_value = principal + repo.get.return_value = portfolio or _make_portfolio() + repo.get_all_trades_for_pnl.return_value = trades if trades is not None else [] + return repo + + +def _with_repo(repo: MagicMock) -> ExitStack: + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +# --------------------------------------------------------------------------- +# REQ-040 AC-1 / REQ-042 AC-3: WAC 199,300 scenario end-to-end +# +# buy 100주@10,000 + buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200 +# WAC = (100*10,000 + 100*12,000) / 200 = 11,000 +# realized_pnl = (15,000 - 11,000) * 50 - 500 - 200 = 199,300 +# --------------------------------------------------------------------------- + +class TestWac199300EndToEnd: + def _make_trades(self) -> list[LedgerEntry]: + return [ + _make_entry( + ledger_entry_id=1, side="buy", quantity=100, price=10_000, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), + ), + _make_entry( + ledger_entry_id=2, side="buy", quantity=100, price=12_000, + executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc), + ), + _make_entry( + ledger_entry_id=3, side="sell", quantity=50, price=15_000, + fee=500, tax=200, + executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc), + ), + ] + + def test_total_realized_pnl_is_199300(self) -> None: + settings = _make_settings() + repo = _make_repo(trades=self._make_trades()) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["total_realized_pnl"] == pytest.approx(199_300.0) + + def test_cost_basis_per_unit_is_11000(self) -> None: + settings = _make_settings() + repo = _make_repo(trades=self._make_trades()) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + body = resp.json() + assert len(body["items"]) == 1 + assert body["items"][0]["cost_basis_per_unit"] == pytest.approx(11_000.0) + + def test_items_contain_required_fields(self) -> None: + settings = _make_settings() + repo = _make_repo(trades=self._make_trades()) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + item = resp.json()["items"][0] + for field in ("executed_at", "quantity", "sell_price", "cost_basis_per_unit", + "fee_amount", "tax_amount", "realized_pnl"): + assert field in item, f"Missing field: {field}" + + def test_data_completeness_warning_is_null_when_complete(self) -> None: + settings = _make_settings() + repo = _make_repo(trades=self._make_trades()) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + body = resp.json() + assert body["data_completeness_warning"] is None + + def test_cost_basis_from_is_first_buy_date(self) -> None: + settings = _make_settings() + repo = _make_repo(trades=self._make_trades()) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + body = resp.json() + assert body["cost_basis_from"] is not None + + +# --------------------------------------------------------------------------- +# REQ-040 AC-3: from/to missing → 422 +# --------------------------------------------------------------------------- + +def test_pnl_missing_from_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"to": "2026-06-30"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +def test_pnl_missing_to_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"from": "2026-06-01"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +def test_pnl_both_missing_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# REQ-040 AC-4: no sells in period → 200 + total=0 + items=[] +# --------------------------------------------------------------------------- + +def test_pnl_no_sells_returns_200_with_zero() -> None: + trades = [ + _make_entry(ledger_entry_id=1, side="buy", quantity=100, price=10_000, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["total_realized_pnl"] == pytest.approx(0.0) + assert body["items"] == [] + + +def test_pnl_empty_ledger_returns_200_with_zero() -> None: + settings = _make_settings() + repo = _make_repo(trades=[]) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["total_realized_pnl"] == pytest.approx(0.0) + assert body["items"] == [] + + +# --------------------------------------------------------------------------- +# REQ-040 AC-5/AC-6: data_completeness_warning (R-1 완화) +# --------------------------------------------------------------------------- + +def test_pnl_sell_without_buy_triggers_warning() -> None: + """AC-5: history incomplete → warning non-null.""" + trades = [ + _make_entry(ledger_entry_id=1, side="sell", quantity=50, price=15_000, fee=500, tax=200, + executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["data_completeness_warning"] is not None + assert len(body["data_completeness_warning"]) > 0 + + +def test_pnl_complete_history_warning_is_null() -> None: + """AC-6: complete history → warning null.""" + trades = [ + _make_entry(ledger_entry_id=1, side="buy", quantity=100, price=10_000, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + _make_entry(ledger_entry_id=2, side="sell", quantity=50, price=15_000, fee=100, tax=50, + executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + assert resp.json()["data_completeness_warning"] is None + + +def test_pnl_oversell_triggers_warning() -> None: + """AC-5: running_qty < sell qty → warning.""" + trades = [ + _make_entry(ledger_entry_id=1, side="buy", quantity=10, price=10_000, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + _make_entry(ledger_entry_id=2, side="sell", quantity=20, price=15_000, + executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + assert resp.json()["data_completeness_warning"] is not None + + +# --------------------------------------------------------------------------- +# REQ-040 AC-2: symbol filter +# --------------------------------------------------------------------------- + +def test_pnl_symbol_filter_passed_to_repo() -> None: + """Symbol filter param is accepted; repo.get_all_trades_for_pnl is called with symbol.""" + settings = _make_settings() + repo = _make_repo(trades=[]) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get( + _PATH, + params={"from": "2026-06-01", "to": "2026-06-30", "symbol": "005930.KS"}, + headers={"Authorization": "Bearer tok"}, + ) + assert resp.status_code == 200 + repo.get_all_trades_for_pnl.assert_called_once_with(_PORTFOLIO_ID, symbol="005930.KS") + + +def test_pnl_symbol_filter_excludes_other_symbol_sells() -> None: + """Portfolio symbol is '005930'; sells tagged with portfolio symbol only appear.""" + portfolio = _make_portfolio(symbol="005930") + trades = [ + _make_entry(ledger_entry_id=1, side="buy", quantity=100, price=10_000, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + _make_entry(ledger_entry_id=2, side="sell", quantity=50, price=15_000, + executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(portfolio=portfolio, trades=trades) + with _with_repo(repo): + client = _make_client(settings) + # symbol filter matches portfolio symbol → sell included + resp = client.get( + _PATH, + params={"from": "2026-06-01", "to": "2026-06-30", "symbol": "005930"}, + headers={"Authorization": "Bearer tok"}, + ) + assert resp.status_code == 200 + body = resp.json() + # portfolio symbol "005930" matches filter "005930" → sell is included + assert len(body["items"]) == 1 + + +# --------------------------------------------------------------------------- +# REQ-040 AC-7: non-owner token → 403 +# --------------------------------------------------------------------------- + +def test_pnl_non_owner_returns_403() -> None: + settings = _make_settings() + repo = _make_repo(portfolio=_make_portfolio(owner=_USER_ID), user_id=_OTHER_USER_ID) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# REQ-040 AC-8: non-existent portfolio → 404 +# --------------------------------------------------------------------------- + +def test_pnl_nonexistent_portfolio_returns_404() -> None: + settings = _make_settings() + repo = _make_repo() + repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다") + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params=_DEFAULT_PARAMS, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# REQ-040 AC-9: endpoint documented in OpenAPI +# --------------------------------------------------------------------------- + +def test_pnl_endpoint_in_openapi() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get("/openapi.json") + assert resp.status_code == 200 + openapi = resp.json() + paths = openapi.get("paths", {}) + pnl_path = f"/v1/portfolios/{{portfolio_id}}/pnl/realized" + assert pnl_path in paths, f"Expected {pnl_path!r} in OpenAPI paths" + assert "get" in paths[pnl_path] diff --git a/tests/test_v1_portfolios_trades.py b/tests/test_v1_portfolios_trades.py new file mode 100644 index 0000000..5e6da4b --- /dev/null +++ b/tests/test_v1_portfolios_trades.py @@ -0,0 +1,302 @@ +"""Tests for GET /v1/portfolios/{portfolio_id}/trades (REQ-039, REQ-042). + +Uses TestClient with MagicMock repo injection — no DB required. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal +from app.main import create_app + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +_USER_ID = "user-001" +_OTHER_USER_ID = "user-002" +_PORTFOLIO_ID = "pf-trades-001" +_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/trades" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_settings() -> Settings: + get_settings.cache_clear() + return Settings( + db_enabled=True, + credential_encryption_key=_ENC_KEY, + allowed_origins_raw="http://localhost:3000", + ) + + +def _make_client(settings: Settings) -> TestClient: + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _make_portfolio(*, owner: str = _USER_ID, status: str = "enabled") -> Portfolio: + return Portfolio( + portfolio_id=_PORTFOLIO_ID, + broker="kis", + status=status, + name="test-portfolio", + symbol="005930", + asset_id="asset-001", + owner_user_id=owner, + cash_balance=1_000_000.0, + asset_quantity=0.0, + tag_ids=[], + managed_by_daemon_id=None, + ) + + +def _make_ledger_entry( + *, + ledger_entry_id: int = 1, + side: str = "buy", + quantity: float = 100.0, + price: float = 10_000.0, + fee: float = 0.0, + tax: float = 0.0, + executed_at: datetime | None = None, +) -> LedgerEntry: + if executed_at is None: + executed_at = datetime(2026, 6, 15, 9, 0, 0, tzinfo=timezone.utc) + return LedgerEntry( + ledger_entry_id=ledger_entry_id, + portfolio_id=_PORTFOLIO_ID, + order_id=f"ord-{ledger_entry_id:03d}", + execution_id=f"exec-{ledger_entry_id:03d}", + side=side, + quantity=quantity, + price=price, + fee_amount=fee, + tax_amount=tax, + executed_at=executed_at, + recorded_at=executed_at, + ) + + +def _make_repo( + *, + portfolio: Portfolio | None = None, + trades: list[LedgerEntry] | None = None, + user_id: str = _USER_ID, + permission: str = "read", +) -> MagicMock: + repo = MagicMock() + principal = TokenPrincipal( + token_id="t1", + user_id=user_id, + permission=permission, + token_type="db", + runtime_id=None, + daemon_id=None, + ) + repo.get_token_principal.return_value = principal + repo.get.return_value = portfolio or _make_portfolio() + repo.list_trades_by_date.return_value = trades if trades is not None else [] + return repo + + +def _with_repo(repo: MagicMock) -> ExitStack: + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +# --------------------------------------------------------------------------- +# AC-1: date=YYYY-MM-DD single date → 200 + items count matches +# --------------------------------------------------------------------------- + +def test_trades_single_date_returns_matching_count() -> None: + """AC-1: date param → HTTP 200 + items list.""" + trades = [ + _make_ledger_entry(ledger_entry_id=1, executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)), + _make_ledger_entry(ledger_entry_id=2, side="sell", executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + _make_ledger_entry(ledger_entry_id=3, executed_at=datetime(2026, 6, 15, 11, 0, tzinfo=timezone.utc)), + ] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 3 + assert len(body["items"]) == 3 + + +def test_trades_single_date_item_fields() -> None: + """AC-1: each item has required fields.""" + trade = _make_ledger_entry(ledger_entry_id=1, side="buy", quantity=100.0, price=10_000.0, + fee=50.0, tax=25.0, + executed_at=datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)) + settings = _make_settings() + repo = _make_repo(trades=[trade]) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert item["side"] == "buy" + assert item["quantity"] == 100.0 + assert item["price"] == 10_000.0 + assert item["fee_amount"] == 50.0 + assert item["tax_amount"] == 25.0 + assert "executed_at" in item + assert "order_id" in item + assert "execution_id" in item + + +# --------------------------------------------------------------------------- +# AC-2: from/to range filter → 200 + items from boundary dates +# --------------------------------------------------------------------------- + +def test_trades_range_filter_returns_items() -> None: + """AC-2: from/to → HTTP 200 + items count.""" + trades = [_make_ledger_entry(ledger_entry_id=i) for i in range(1, 8)] + settings = _make_settings() + repo = _make_repo(trades=trades) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"from": "2026-06-01", "to": "2026-06-15"}, + headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + assert resp.json()["total"] == 7 + + +# --------------------------------------------------------------------------- +# AC-3: no date params → HTTP 422 +# --------------------------------------------------------------------------- + +def test_trades_no_date_params_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# AC-4: bad date format → HTTP 422 +# --------------------------------------------------------------------------- + +def test_trades_bad_date_format_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + # "20260615" is not YYYY-MM-DD + resp = client.get(_PATH, params={"date": "20260615"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +def test_trades_slash_date_format_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026/06/15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# AC-5: no trades in range → 200 + empty items +# --------------------------------------------------------------------------- + +def test_trades_empty_range_returns_200_empty() -> None: + settings = _make_settings() + repo = _make_repo(trades=[]) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 0 + assert body["items"] == [] + + +# --------------------------------------------------------------------------- +# AC-6: non-owner token → HTTP 403 +# --------------------------------------------------------------------------- + +def test_trades_non_owner_returns_403() -> None: + settings = _make_settings() + # Repo returns a portfolio owned by _USER_ID, but token belongs to _OTHER_USER_ID + repo = _make_repo(portfolio=_make_portfolio(owner=_USER_ID), user_id=_OTHER_USER_ID) + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# AC-7: non-existent portfolio → HTTP 404 +# --------------------------------------------------------------------------- + +def test_trades_nonexistent_portfolio_returns_404() -> None: + settings = _make_settings() + repo = _make_repo() + repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다") + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"date": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# AC-8: endpoint is documented in OpenAPI schema +# --------------------------------------------------------------------------- + +def test_trades_endpoint_in_openapi() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get("/openapi.json") + assert resp.status_code == 200 + openapi = resp.json() + paths = openapi.get("paths", {}) + trade_path = f"/v1/portfolios/{{portfolio_id}}/trades" + assert trade_path in paths, f"Expected {trade_path!r} in OpenAPI paths" + assert "get" in paths[trade_path] + + +# --------------------------------------------------------------------------- +# Partial range: from-only or to-only → 422 +# --------------------------------------------------------------------------- + +def test_trades_from_only_without_to_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"from": "2026-06-01"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 + + +def test_trades_to_only_without_from_returns_422() -> None: + settings = _make_settings() + repo = _make_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.get(_PATH, params={"to": "2026-06-15"}, headers={"Authorization": "Bearer tok"}) + assert resp.status_code == 422 diff --git a/tests/test_wac_calculation.py b/tests/test_wac_calculation.py new file mode 100644 index 0000000..26fffd6 --- /dev/null +++ b/tests/test_wac_calculation.py @@ -0,0 +1,277 @@ +"""Pure unit tests for WAC cost_basis PnL calculation (REQ-041, REQ-042). + +These tests have NO DB dependency and must pass without any database container. +All inputs are plain Python dicts; no psycopg/DB imports are used. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.domain.pnl import WacPnlResult, calculate_realized_pnl + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_T0 = datetime(2026, 6, 1, 9, 0, 0, tzinfo=timezone.utc) +_T1 = datetime(2026, 6, 1, 9, 30, 0, tzinfo=timezone.utc) +_T2 = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc) +_T3 = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + + +def _row(side: str, qty: float, price: float, *, fee: float = 0.0, tax: float = 0.0, + executed_at: datetime = _T0, symbol: str | None = "005930") -> dict: + return { + "side": side, + "quantity": qty, + "price": price, + "fee_amount": fee, + "tax_amount": tax, + "executed_at": executed_at, + "order_id": None, + "execution_id": None, + "symbol": symbol, + } + + +# --------------------------------------------------------------------------- +# REQ-042 AC-1 / REQ-041 AC-1: WAC 199,300 scenario +# --------------------------------------------------------------------------- + +class TestWac199300: + """buy 100주@10,000 + buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200. + + WAC = (100*10,000 + 100*12,000) / 200 = 11,000 + realized_pnl = (15,000 - 11,000) * 50 - 500 - 200 = 200,000 - 700 = 199,300 + """ + + def _make_rows(self) -> list[dict]: + return [ + _row("buy", 100, 10_000, executed_at=_T0), + _row("buy", 100, 12_000, executed_at=_T1), + _row("sell", 50, 15_000, fee=500, tax=200, executed_at=_T2), + ] + + def test_total_realized_pnl(self) -> None: + result = calculate_realized_pnl(self._make_rows()) + assert result.total_realized_pnl == pytest.approx(199_300.0) + + def test_cost_basis_per_unit(self) -> None: + result = calculate_realized_pnl(self._make_rows()) + assert len(result.items) == 1 + assert result.items[0].cost_basis_per_unit == pytest.approx(11_000.0) + + def test_no_warning_when_history_complete(self) -> None: + result = calculate_realized_pnl(self._make_rows()) + assert result.data_completeness_warning is None + + def test_cost_basis_from_is_first_buy(self) -> None: + result = calculate_realized_pnl(self._make_rows()) + assert result.cost_basis_from == _T0 + + def test_sell_item_fields(self) -> None: + result = calculate_realized_pnl(self._make_rows()) + item = result.items[0] + assert item.sell_price == pytest.approx(15_000.0) + assert item.quantity == pytest.approx(50.0) + assert item.fee_amount == pytest.approx(500.0) + assert item.tax_amount == pytest.approx(200.0) + + +# --------------------------------------------------------------------------- +# REQ-041 AC-2: buy only, no sell → items=[], total=0, cost_basis_from set +# --------------------------------------------------------------------------- + +class TestBuyOnlyNoSell: + def test_empty_items(self) -> None: + rows = [_row("buy", 100, 10_000, executed_at=_T0)] + result = calculate_realized_pnl(rows) + assert result.items == [] + assert result.total_realized_pnl == pytest.approx(0.0) + + def test_cost_basis_from_set(self) -> None: + rows = [_row("buy", 100, 10_000, executed_at=_T0)] + result = calculate_realized_pnl(rows) + assert result.cost_basis_from == _T0 + + def test_no_warning(self) -> None: + rows = [_row("buy", 100, 10_000, executed_at=_T0)] + result = calculate_realized_pnl(rows) + assert result.data_completeness_warning is None + + +# --------------------------------------------------------------------------- +# REQ-042 AC-1 / REQ-041 AC-6: empty input → total=0, cost_basis_from=None +# --------------------------------------------------------------------------- + +class TestEmptyInput: + def test_empty_rows(self) -> None: + result = calculate_realized_pnl([]) + assert result.items == [] + assert result.total_realized_pnl == pytest.approx(0.0) + assert result.cost_basis_from is None + assert result.data_completeness_warning is None + + +# --------------------------------------------------------------------------- +# REQ-042 AC-1 / REQ-041 AC-3: sell with no prior buy → warning +# --------------------------------------------------------------------------- + +class TestSellWithNoBuyHistory: + def test_warning_is_set(self) -> None: + rows = [_row("sell", 10, 15_000, fee=100, tax=50, executed_at=_T0)] + result = calculate_realized_pnl(rows) + assert result.data_completeness_warning is not None + assert len(result.data_completeness_warning) > 0 + + def test_calculation_continues(self) -> None: + """REQ-041: 중단 없이 최선 추정 계속.""" + rows = [_row("sell", 10, 15_000, fee=100, tax=50, executed_at=_T0)] + result = calculate_realized_pnl(rows) + # WAC=0 when no buy history; realized = (15000-0)*10 - 100 - 50 + assert len(result.items) == 1 + + +# --------------------------------------------------------------------------- +# REQ-042 AC-1 / REQ-041 AC-4: sell quantity exceeds running_qty → warning +# --------------------------------------------------------------------------- + +class TestSellExceedsRunningQty: + def test_warning_when_oversell(self) -> None: + rows = [ + _row("buy", 10, 10_000, executed_at=_T0), + _row("sell", 20, 15_000, executed_at=_T1), # oversell: 20 > 10 + ] + result = calculate_realized_pnl(rows) + assert result.data_completeness_warning is not None + assert len(result.data_completeness_warning) > 0 + + def test_calculation_not_aborted(self) -> None: + rows = [ + _row("buy", 10, 10_000, executed_at=_T0), + _row("sell", 20, 15_000, executed_at=_T1), + ] + result = calculate_realized_pnl(rows) + assert len(result.items) == 1 # sell item still produced + + +# --------------------------------------------------------------------------- +# REQ-041 AC-5: multiple buy/sell cycles — WAC accumulates correctly +# --------------------------------------------------------------------------- + +class TestMultipleBuySellCycles: + def test_wac_is_cumulative(self) -> None: + """After buy100@10k then buy100@12k then sell50@15k, remaining WAC stays 11k.""" + rows = [ + _row("buy", 100, 10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + _row("buy", 100, 12_000, executed_at=datetime(2026, 6, 1, 9, 30, tzinfo=timezone.utc)), + _row("sell", 50, 15_000, fee=500, tax=200, executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=timezone.utc)), + # After sell: running_qty = 150, WAC = 11,000 + # Buy more at 13,000: new WAC = (11000*150 + 13000*50) / 200 = (1,650,000 + 650,000) / 200 = 11,500 + _row("buy", 50, 13_000, executed_at=datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc)), + _row("sell", 100, 16_000, fee=300, tax=100, executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + result = calculate_realized_pnl(rows) + assert len(result.items) == 2 + + # First sell: WAC=11,000, qty=50, fee=500, tax=200 + item0 = result.items[0] + assert item0.cost_basis_per_unit == pytest.approx(11_000.0) + assert item0.realized_pnl == pytest.approx((15_000 - 11_000) * 50 - 500 - 200) + + # Second sell: WAC=11,500, qty=100, fee=300, tax=100 + item1 = result.items[1] + assert item1.cost_basis_per_unit == pytest.approx(11_500.0) + assert item1.realized_pnl == pytest.approx((16_000 - 11_500) * 100 - 300 - 100) + + +# --------------------------------------------------------------------------- +# Date filter: from_dt / to_dt +# --------------------------------------------------------------------------- + +class TestDateFilter: + def _make_rows(self) -> list[dict]: + """buy once, then two sells on different days.""" + return [ + _row("buy", 200, 10_000, executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)), + _row("sell", 50, 15_000, fee=100, tax=50, + executed_at=datetime(2026, 6, 10, 10, 0, tzinfo=timezone.utc)), + _row("sell", 50, 16_000, fee=100, tax=50, + executed_at=datetime(2026, 6, 20, 10, 0, tzinfo=timezone.utc)), + ] + + def test_filter_includes_only_sells_in_range(self) -> None: + from_dt = datetime(2026, 6, 10, 0, 0, 0, tzinfo=timezone.utc) + to_dt = datetime(2026, 6, 11, 0, 0, 0, tzinfo=timezone.utc) # exclusive upper + result = calculate_realized_pnl(self._make_rows(), from_dt=from_dt, to_dt=to_dt) + assert len(result.items) == 1 + assert result.items[0].sell_price == pytest.approx(15_000.0) + + def test_filter_both_sells_in_range(self) -> None: + from_dt = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + to_dt = datetime(2026, 6, 30, 0, 0, 0, tzinfo=timezone.utc) + result = calculate_realized_pnl(self._make_rows(), from_dt=from_dt, to_dt=to_dt) + assert len(result.items) == 2 + + def test_wac_uses_all_buys_regardless_of_filter(self) -> None: + """WAC must be computed from ALL buy history, even if buy is before from_dt.""" + rows = [ + _row("buy", 100, 10_000, executed_at=datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc)), + _row("sell", 50, 15_000, executed_at=datetime(2026, 6, 15, 10, 0, tzinfo=timezone.utc)), + ] + from_dt = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + to_dt = datetime(2026, 6, 30, 0, 0, 0, tzinfo=timezone.utc) + result = calculate_realized_pnl(rows, from_dt=from_dt, to_dt=to_dt) + assert len(result.items) == 1 + assert result.items[0].cost_basis_per_unit == pytest.approx(10_000.0) + + +# --------------------------------------------------------------------------- +# Symbol filter +# --------------------------------------------------------------------------- + +class TestSymbolFilter: + def test_symbol_filter_excludes_other_symbols(self) -> None: + rows = [ + _row("buy", 100, 10_000, symbol="005930"), + _row("sell", 50, 15_000, symbol="005930"), + _row("sell", 30, 20_000, symbol="OTHER"), + ] + result = calculate_realized_pnl(rows, symbol="005930") + # Only the 005930 sell should appear + assert len(result.items) == 1 + assert result.items[0].sell_price == pytest.approx(15_000.0) + + def test_no_symbol_filter_includes_all(self) -> None: + rows = [ + _row("buy", 200, 10_000), + _row("sell", 50, 15_000), + _row("sell", 30, 16_000), + ] + result = calculate_realized_pnl(rows) + assert len(result.items) == 2 + + +# --------------------------------------------------------------------------- +# REQ-041 AC-7: no psycopg import in domain module (import-time check) +# --------------------------------------------------------------------------- + +class TestNoPsycopgInDomainModule: + def test_pnl_module_has_no_db_import(self) -> None: + import importlib + import sys + module_name = "app.domain.pnl" + # Force reimport to check + mod = sys.modules.get(module_name) + if mod is None: + mod = importlib.import_module(module_name) + source_file = getattr(mod, "__file__", "") + with open(source_file) as f: + source = f.read() + for forbidden in ("psycopg", "asyncpg", "psycopg2"): + assert forbidden not in source, ( + f"app/domain/pnl.py contains forbidden DB import: {forbidden!r}" + )