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
This commit is contained in:
@@ -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 유지).
|
||||
Reference in New Issue
Block a user