Files
portfolio-hub/docs/batches/bat-017_20260618_trade-history-and-pnl-api/design.md
T
genichinandClaude Opus 4.8 bd34450ee9 feat(pnl): trade history + realized PnL API (WAC) — BAT-017, REQ-039~042 Implemented
- GET /v1/portfolios/{id}/trades and GET /v1/portfolios/{id}/pnl/realized endpoints
- Weighted Average Cost (WAC) realized PnL calculation in app/domain/pnl.py
- cost_basis and data_completeness_warning returned in pnl API response
- Test coverage: 50 passed (WAC 20 + trades 12 + pnl 18), full 427 passed regression ✓
- WAC validated against 199,300 unit e2e test cases
- DB migration: none required
- QC: qc-reviewer PASS-WITH-NOTES; team lead confirm-batch-verification → release-candidate
- Remaining non-blocking: symbol filter edge case testing, HTTP_422 deprecation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-18 16:45:01 +09:00

4.7 KiB

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 계산 함수 인터페이스

# 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

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

-- 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 유지).