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