Merge pull request #15 from fourblocks2020/feat/req-041-wac-buy-fee-capitalize

fix(REQ-041): WAC cost basis에 매수 수수료 편입 (dcy-035 연동)
This commit is contained in:
2026-06-26 20:52:12 +09:00
committed by GitHub
8 changed files with 281 additions and 16 deletions
+1
View File
@@ -859,6 +859,7 @@ def get_holdings_cost_basis(
"side": e.side, "side": e.side,
"quantity": e.quantity, "quantity": e.quantity,
"price": e.price, "price": e.price,
"fee_amount": e.fee_amount,
"executed_at": e.executed_at, "executed_at": e.executed_at,
} }
for e in ledger_entries for e in ledger_entries
+11 -7
View File
@@ -84,9 +84,10 @@ def calculate_realized_pnl(
WacPnlResult with per-sell PnL items, total, cost_basis_from, WacPnlResult with per-sell PnL items, total, cost_basis_from,
and data_completeness_warning when history is incomplete. and data_completeness_warning when history is incomplete.
WAC Rules (REQ-041): WAC Rules (REQ-041, buy-fee-in-cost-basis):
buy: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty) buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
running_qty += qty running_qty += qty
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
sell: realized_pnl = (sell_price - running_wac) * qty - fee - tax sell: realized_pnl = (sell_price - running_wac) * qty - fee - tax
running_qty -= qty running_qty -= qty
Incomplete history (running_qty < sell_qty or sell with running_qty=0): Incomplete history (running_qty < sell_qty or sell with running_qty=0):
@@ -114,10 +115,10 @@ def calculate_realized_pnl(
execution_id: str | None = row.get("execution_id") execution_id: str | None = row.get("execution_id")
if side == "buy": if side == "buy":
# Update running WAC # Update running WAC — include buy fee in cost basis (매수 수수료 편입)
new_total_qty = running_qty + qty new_total_qty = running_qty + qty
if new_total_qty > 0: if new_total_qty > 0:
running_wac = (running_wac * running_qty + price * qty) / new_total_qty running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
running_qty = new_total_qty running_qty = new_total_qty
# Track earliest buy for cost_basis_from # Track earliest buy for cost_basis_from
if cost_basis_from is None and executed_at is not None: if cost_basis_from is None and executed_at is not None:
@@ -204,9 +205,10 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
- cost_basis_from: earliest buy date used - cost_basis_from: earliest buy date used
- data_completeness_warning: non-None when history is incomplete - data_completeness_warning: non-None when history is incomplete
WAC Rules (same as calculate_realized_pnl, REQ-041): WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis):
buy: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty) buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
running_qty += qty running_qty += qty
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
sell: running_qty -= qty (WAC per unit unchanged on sell) sell: running_qty -= qty (WAC per unit unchanged on sell)
Incomplete history (sell with running_qty=0, or sell qty > running_qty): Incomplete history (sell with running_qty=0, or sell qty > running_qty):
data_completeness_warning is set; calculation continues with best estimate. data_completeness_warning is set; calculation continues with best estimate.
@@ -228,12 +230,14 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
side = str(row.get("side", "")).lower() side = str(row.get("side", "")).lower()
qty = float(row.get("quantity", 0.0)) qty = float(row.get("quantity", 0.0))
price = float(row.get("price", 0.0)) price = float(row.get("price", 0.0))
fee = float(row.get("fee_amount", 0.0))
executed_at: datetime | None = row.get("executed_at") executed_at: datetime | None = row.get("executed_at")
if side == "buy": if side == "buy":
# Include buy fee in cost basis (매수 수수료 편입)
new_total_qty = running_qty + qty new_total_qty = running_qty + qty
if new_total_qty > 0: if new_total_qty > 0:
running_wac = (running_wac * running_qty + price * qty) / new_total_qty running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
running_qty = new_total_qty running_qty = new_total_qty
if cost_basis_from is None and executed_at is not None: if cost_basis_from is None and executed_at is not None:
cost_basis_from = executed_at cost_basis_from = executed_at
@@ -14,22 +14,26 @@
- WAC cost_basis 계산 함수는 `app/services/` 또는 `app/domain/` 계층에 위치해야 하며, repository(psycopg/SQL) 직접 의존성 없이 순수 Python으로 구현해야 한다. - WAC cost_basis 계산 함수는 `app/services/` 또는 `app/domain/` 계층에 위치해야 하며, repository(psycopg/SQL) 직접 의존성 없이 순수 Python으로 구현해야 한다.
- 입력: portfolio_ledger 행 목록(side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id 포함) — 시간 순 정렬. - 입력: portfolio_ledger 행 목록(side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id 포함) — 시간 순 정렬.
- 출력: 건별 매도에 대한 실현 PnL 계산 결과 목록, cost_basis_from(첫 buy 기록 시점), data_completeness_warning(이력 불완전 감지 시 경고 문자열, 없으면 None). - 출력: 건별 매도에 대한 실현 PnL 계산 결과 목록, cost_basis_from(첫 buy 기록 시점), data_completeness_warning(이력 불완전 감지 시 경고 문자열, 없으면 None).
- WAC 계산 규칙: - WAC 계산 규칙 (**매수 수수료 cost basis 편입 포함, 2026-06-26 확정**):
- buy 시: `running_wac = (running_wac × running_qty + price × qty) / (running_qty + qty)`, `running_qty += qty`. - buy 시: `running_wac = (running_wac × running_qty + price × qty + buy_fee) / (running_qty + qty)`, `running_qty += qty`.
- sell 시: `realized_pnl = (sell_price − running_wac) × qty − fee − tax`, `running_qty -= qty`. - `buy_fee`는 해당 buy 행의 `fee_amount`. 매수 수수료를 cost basis에 포함함으로써 실현 PnL이 "당일 net"과 정확히 일치한다.
- sell 시: `realized_pnl = (sell_price − running_wac) × qty − sell_fee − tax`, `running_qty -= qty`.
- sell fee는 running_wac에 편입하지 않고 sell 시 차감한다(기존 방식 유지).
- 매도 수량이 보유 수량을 초과(running_qty < qty)하는 경우: 이력 불완전으로 판단하고 `data_completeness_warning` 반환 + 최선 추정으로 계속 계산(중단하지 않음). - 매도 수량이 보유 수량을 초과(running_qty < qty)하는 경우: 이력 불완전으로 판단하고 `data_completeness_warning` 반환 + 최선 추정으로 계속 계산(중단하지 않음).
- cost_basis_from은 입력 행 목록에서 가장 이른 buy 기록의 executed_at으로 설정. buy 기록이 없으면 None. - cost_basis_from은 입력 행 목록에서 가장 이른 buy 기록의 executed_at으로 설정. buy 기록이 없으면 None.
- 이 함수는 repository를 통해 얻은 원시 행 목록을 받으므로, repository는 관련 buy/sell 이력을 충분한 범위로 조회해 전달해야 한다(repository와 계층 경계 명시). - 이 함수는 repository를 통해 얻은 원시 행 목록을 받으므로, repository는 관련 buy/sell 이력을 충분한 범위로 조회해 전달해야 한다(repository와 계층 경계 명시).
## Acceptance Criteria ## Acceptance Criteria
1. Given buy 100주@10,000 → buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200 순 행 목록 입력, When WAC 계산 함수 호출, Then cost_basis_per_unit=11,000(WAC = (100×10,000 + 100×12,000) / 200) + realized_pnl=199,300((15,000-11,000)×50 - 500 - 200) 반환. 1. Given buy 100주@10,000 fee=0 → buy 100주@12,000 fee=0 → sell 50주@15,000 fee=500 tax=200 순 행 목록 입력(buy fee=0이므로 기존 수치 유지), When WAC 계산 함수 호출, Then cost_basis_per_unit=11,000(WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200) + realized_pnl=199,300((15,000-11,000)×50 - 500 - 200) 반환.
2. Given buy 100주@10,000만 이력이 있고 sell 없음, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=buy 시각 + data_completeness_warning=None 반환. 2. Given buy 100주@10,000만 이력이 있고 sell 없음, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=buy 시각 + data_completeness_warning=None 반환.
3. Given 이력이 sell부터 시작(buy 이력 없음 = running_qty=0에서 sell 발생), When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환 + 계산은 중단하지 않고 최선 추정 계속. 3. Given 이력이 sell부터 시작(buy 이력 없음 = running_qty=0에서 sell 발생), When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환 + 계산은 중단하지 않고 최선 추정 계속.
4. Given 매도 수량이 현재 running_qty를 초과하는 경우, When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환. 4. Given 매도 수량이 현재 running_qty를 초과하는 경우, When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환.
5. Given 동일 포트폴리오에 여러 번 buy/sell 반복(시간 순 정렬 입력 10건), When 함수 호출, Then 각 sell 시점 WAC가 해당 시점까지의 buy 누적 가중평균을 정확히 반영. 5. Given 동일 포트폴리오에 여러 번 buy/sell 반복(시간 순 정렬 입력 10건), When 함수 호출, Then 각 sell 시점 WAC가 해당 시점까지의 buy 누적 가중평균(buy fee 포함)을 정확히 반영.
6. Given 입력 행 목록이 빈 배열, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=None + data_completeness_warning=None 반환. 6. Given 입력 행 목록이 빈 배열, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=None + data_completeness_warning=None 반환.
7. Given 이 계산 함수가 `app/services/` 또는 `app/domain/` 계층에 위치, When `grep -rn "psycopg\|asyncpg\|psycopg2" app/services/ app/domain/` 실행, Then WAC 계산 함수 파일에서 DB 라이브러리 직접 import가 0건(순수 Python 격리 확인, NFR-3 반영). 7. Given 이 계산 함수가 `app/services/` 또는 `app/domain/` 계층에 위치, When `grep -rn "psycopg\|asyncpg\|psycopg2" app/services/ app/domain/` 실행, Then WAC 계산 함수 파일에서 DB 라이브러리 직접 import가 0건(순수 Python 격리 확인, NFR-3 반영).
8. Given 계산 함수가 구현된 모듈, When 단위 테스트(`tests/test_wac_calculation.py`)를 pytest로 실행, Then DB 컨테이너 없이 통과(--no-db 마커 또는 DB 의존성 없는 pytest 실행). 8. Given 계산 함수가 구현된 모듈, When 단위 테스트(`tests/test_wac_calculation.py`)를 pytest로 실행, Then DB 컨테이너 없이 통과(--no-db 마커 또는 DB 의존성 없는 pytest 실행).
9. Given buy 10주@1,000 fee=5, sell 10주@1,100 fee=5 tax=33(일중 닫힌 거래), When WAC 계산 함수 호출, Then cost_basis_per_unit=1,000.5(WAC = (1000×10+5)/10) + realized_pnl=957((1100-1000.5)×10-5-33). 이 값이 "당일 net"(= 1100×10 - 1000×10 - 5 - 5 - 33 = 957)과 정확히 일치.
10. Given calculate_current_wac 함수에 buy 10주@1,000 fee=5 입력, When 호출, Then current_wac=1,000.5(buy fee 편입) + current_quantity=10.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -49,6 +53,48 @@
## Change Log ## Change Log
### CHG-20260626-01
- Date: 2026-06-26
- Author: Justin Ko (portfolio-hub-dev)
#### Change Summary
- WAC 공식에 **매수 수수료 cost basis 편입** 적용. 사장님 결정(2026-06-26): 매수 fee를 WAC에 포함해 실현 PnL이 "당일 net"과 정확히 일치하도록.
#### Intent
- 기존 공식 `running_wac = (running_wac*qty + price*qty) / new_total_qty`에서 buy row의 `fee_amount`를 분자에 추가.
신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`
- `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 모두 동일하게 적용해 WAC 정의 일관성 유지.
- trex-core dcy-035 멀티데이 운영리포트가 이 API를 소비하며, 일중 닫힌 거래에서 실현 PnL = 당일 net이 정확히 성립한다.
- sell 분기의 `realized_pnl = (price - running_wac)*qty - sell_fee - tax` 공식 자체는 변경 없음. sell fee/tax는 그대로 차감 유지.
#### Acceptance Criteria Delta
- Changed:
- AC-1: WAC 계산에 buy fee=0 명시 (기존 buy fee 0 가정을 명문화해 기댓값 수치 199,300 유지)
- AC-5: "buy 누적 가중평균(buy fee 포함)을 정확히 반영"으로 명시
- Added:
- AC-9: 일중 닫힌 거래 기준 케이스 — buy 10@1000 fee=5, sell 10@1100 fee=5 tax=33 → realized=957, 당일 net=957 일치
- AC-10: calculate_current_wac buy fee 편입 — buy 10@1000 fee=5 → current_wac=1000.5
#### Impacted Area Delta
- Modules:
- `app/domain/pnl.py` — `calculate_realized_pnl` buy 분기 WAC 공식 갱신(fee 편입). `calculate_current_wac` buy 분기에 fee_amount 읽기 추가 및 동일 편입.
- Tests:
- `tests/test_wac_calculation.py` — `TestBuyFeeInCostBasis`(5개) + `TestCurrentWacBuyFeeInCostBasis`(4개) 신규 클래스 추가. 기존 테스트 기댓값 변경 없음(기존 케이스 모두 buy fee=0).
#### Delivery Trace Delta
- Batches: 직접 batch 없음 — 사장님 지시 핫픽스 적용
- Releases: none (미배포)
#### Revalidation Impact
- Reverification Needed: yes (기존 구현 공식이 바뀌므로 재검증)
- Existing Implementation Invalidated: yes (매수 fee>0인 실제 거래에서 기존 realized_pnl 값이 부정확했음)
- Status Recommendation: `Implemented` 유지 — 기존 테스트 기댓값은 모두 buy fee=0 케이스라 기존 테스트 PASS 그대로 유지. 신규 AC-9/AC-10 테스트 통과 확인됨(pytest 456 passed / 0 failed, 2026-06-26 실행).
- Follow-up Action: REQ-040 Notes 갱신(WAC 정의 변경 참조 추가), REQ-043 Notes 갱신(동일). docs/srs/index.md Recent Change Log 갱신.
#### Validation Plan
- pytest tests/test_wac_calculation.py — 29 passed (신규 TestBuyFeeInCostBasis 5개 + TestCurrentWacBuyFeeInCostBasis 4개 포함). DB 불필요.
- pytest 전체 스위트 — 456 passed / 0 failed (2026-06-26 실행 확인).
### CHG-20260618-01 ### CHG-20260618-01
- Date: 2026-06-18 - Date: 2026-06-18
- Author: Justin Ko - Author: Justin Ko
@@ -46,7 +46,8 @@
- `tests/test_v1_portfolios_pnl.py` 신규 — AC-1~10 커버 - `tests/test_v1_portfolios_pnl.py` 신규 — AC-1~10 커버
## Notes ## Notes
- AC-1의 수치 근거: WAC = (100×10,000 + 100×12,000) / 200 = 11,000. 실현 PnL = (15,000 - 11,000) × 50 - 500 - 200 = 200,000 - 700 = 199,300. - AC-1의 수치 근거: WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200 = 11,000(buy fee=0이므로 기존과 동일). 실현 PnL = (15,000 - 11,000) × 50 - 500 - 200 = 200,000 - 700 = 199,300.
- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. 이 엔드포인트가 반환하는 `cost_basis_per_unit` 및 `realized_pnl`은 해당 정의를 따른다. AC-1 수치는 buy fee=0인 케이스라 기댓값 불변.
- `data_completeness_warning` 트리거 조건(이력 불완전 감지 기준)은 REQ-041 구현 단계에서 구체화. 현재 가정: portfolio 생성 시점보다 이른 buy 이력이 없거나, cost_basis 산출 대상 buy 합산 수량이 매도 수량보다 적을 경우. - `data_completeness_warning` 트리거 조건(이력 불완전 감지 기준)은 REQ-041 구현 단계에서 구체화. 현재 가정: portfolio 생성 시점보다 이른 buy 이력이 없거나, cost_basis 산출 대상 buy 합산 수량이 매도 수량보다 적을 경우.
- 미실현 PnL(현재가 연동)은 Out of Scope(dcy-007 범위 경계 확인). - 미실현 PnL(현재가 연동)은 Out of Scope(dcy-007 범위 경계 확인).
- 전체 portfolio 집계 PnL 뷰(OQ-2 결정: Out of Scope) — 단일 portfolio 단위만. - 전체 portfolio 집계 PnL 뷰(OQ-2 결정: Out of Scope) — 단일 portfolio 단위만.
@@ -60,6 +60,7 @@
- **OQ-2(domain 함수 확장 vs 신규) — 구현 단계 위임(팀장 confirm-req, 2026-06-25)**: `calculate_current_wac` 신규 함수 분리(단일책임 원칙) 를 **권고하되**, 구현 옵션((a) 기존 함수 파라미터 확장 vs (b) 신규 함수 분리) 선택은 **구현 단계(design/implementation) 재량으로 위임**한다. REQ에서 강제하지 않는다. domain 계층 순수성 유지는 필수. - **OQ-2(domain 함수 확장 vs 신규) — 구현 단계 위임(팀장 confirm-req, 2026-06-25)**: `calculate_current_wac` 신규 함수 분리(단일책임 원칙) 를 **권고하되**, 구현 옵션((a) 기존 함수 파라미터 확장 vs (b) 신규 함수 분리) 선택은 **구현 단계(design/implementation) 재량으로 위임**한다. REQ에서 강제하지 않는다. domain 계층 순수성 유지는 필수.
- **current_quantity 출처**: `portfolios.asset_quantity`(테이블 컬럼)를 직접 읽는다. 이 값은 ledger_trade 기록 시 `asset_quantity_after`로 동기화되므로 신뢰 가능하다. WAC는 ledger replay로 독립 계산하므로, 두 값의 수량 불일치 시 `data_completeness_warning` 조건이 트리거된다. - **current_quantity 출처**: `portfolios.asset_quantity`(테이블 컬럼)를 직접 읽는다. 이 값은 ledger_trade 기록 시 `asset_quantity_after`로 동기화되므로 신뢰 가능하다. WAC는 ledger replay로 독립 계산하므로, 두 값의 수량 불일치 시 `data_completeness_warning` 조건이 트리거된다.
- **GET /pnl/realized와의 차이**: REQ-040은 지정 기간 내 **매도 완료된** 실현 손익을 반환. 이 REQ는 **현재 보유 중인** 미매도분의 현재 평단을 반환. 개념적으로 보완 관계이며 상호 독립적이다. - **GET /pnl/realized와의 차이**: REQ-040은 지정 기간 내 **매도 완료된** 실현 손익을 반환. 이 REQ는 **현재 보유 중인** 미매도분의 현재 평단을 반환. 개념적으로 보완 관계이며 상호 독립적이다.
- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_current_wac` 함수도 동일 정의를 따르므로 이 엔드포인트의 `cost_basis_per_unit` 역시 매수 fee 포함값이다. AC-1/AC-2의 기댓값은 buy fee=0인 케이스라 불변.
- Source: trex-core dcy-034 FR-7 (cold-start cost basis seed 선행 의존). 근거 Discovery: dcy-008 (portfolio-hub측 정식화). - Source: trex-core dcy-034 FR-7 (cold-start cost basis seed 선행 의존). 근거 Discovery: dcy-008 (portfolio-hub측 정식화).
## Change Log ## Change Log
+1 -1
View File
@@ -127,8 +127,8 @@
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신.
- 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate). - 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate).
- 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지. - 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지.
- 2026-06-25: REQ-043 신규 (Proposed) — trex-core dcy-034 cold-start 평단 seed(FR-7) 선행 의존으로 draft-req 실행. GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 WAC read 엔드포인트 신설(Interface). OQ-1=service token 소유권 정책(daemon_id 일치 여부), OQ-2=domain 함수 확장 vs 신규 분리 — confirm-req에서 결정 필요. - 2026-06-25: REQ-043 신규 (Proposed) — trex-core dcy-034 cold-start 평단 seed(FR-7) 선행 의존으로 draft-req 실행. GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 WAC read 엔드포인트 신설(Interface). OQ-1=service token 소유권 정책(daemon_id 일치 여부), OQ-2=domain 함수 확장 vs 신규 분리 — confirm-req에서 결정 필요.
- 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 `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).
+67 -1
View File
@@ -78,6 +78,7 @@ def _make_entry(
quantity: float, quantity: float,
price: float, price: float,
executed_at: datetime, executed_at: datetime,
fee_amount: float = 0.0,
) -> LedgerEntry: ) -> LedgerEntry:
return LedgerEntry( return LedgerEntry(
ledger_entry_id=ledger_entry_id, ledger_entry_id=ledger_entry_id,
@@ -87,7 +88,7 @@ def _make_entry(
side=side, side=side,
quantity=quantity, quantity=quantity,
price=price, price=price,
fee_amount=0.0, fee_amount=fee_amount,
tax_amount=0.0, tax_amount=0.0,
executed_at=executed_at, executed_at=executed_at,
recorded_at=executed_at, recorded_at=executed_at,
@@ -535,3 +536,68 @@ def test_portfolio_id_in_response_matches_request() -> None:
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["portfolio_id"] == _PORTFOLIO_ID assert resp.json()["portfolio_id"] == _PORTFOLIO_ID
# ---------------------------------------------------------------------------
# QC보정: buy fee>0 케이스 — cost_basis_per_unit이 fee 편입값인지 확인
# buy 10@1000 fee=5 → cost_basis_per_unit = (10*1000 + 5) / 10 = 1000.5
# ---------------------------------------------------------------------------
def test_cost_basis_per_unit_includes_buy_fee() -> None:
"""buy fee>0 케이스: cost_basis_per_unit이 매수 fee 편입값을 반환한다.
buy 10주 @ 1,000원, fee=5원 → WAC = (10*1000 + 5) / 10 = 1000.5
REQ-043 Notes: 매수 fee를 cost basis에 포함.
"""
settings = _make_settings()
trades = [
_make_entry(
ledger_entry_id=1,
side="buy",
quantity=10,
price=1_000,
fee_amount=5.0,
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
),
]
repo = _make_service_repo(
portfolio=_make_portfolio(asset_quantity=10.0),
trades=trades,
)
with _with_repo(repo):
client = _make_client(settings)
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
# (10 * 1000 + 5) / 10 = 1000.5
assert body["cost_basis_per_unit"] == pytest.approx(1000.5)
assert body["total_cost_basis"] == pytest.approx(10_005.0)
def test_cost_basis_per_unit_zero_fee_unchanged() -> None:
"""buy fee=0 케이스: fee 편입 추가 후에도 fee=0이면 기존 값 그대로.
buy 10주 @ 1,000원, fee=0 → WAC = 1000.0 (회귀 확인)
"""
settings = _make_settings()
trades = [
_make_entry(
ledger_entry_id=1,
side="buy",
quantity=10,
price=1_000,
fee_amount=0.0,
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
),
]
repo = _make_service_repo(
portfolio=_make_portfolio(asset_quantity=10.0),
trades=trades,
)
with _with_repo(repo):
client = _make_client(settings)
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
assert body["cost_basis_per_unit"] == pytest.approx(1000.0)
assert body["total_cost_basis"] == pytest.approx(10_000.0)
+147 -1
View File
@@ -2,6 +2,9 @@
These tests have NO DB dependency and must pass without any database container. 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. All inputs are plain Python dicts; no psycopg/DB imports are used.
Note: WAC formula includes buy fee in cost basis (매수 수수료 편입, 2026-06-26).
buy: running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,7 +13,7 @@ from datetime import datetime, timezone
import pytest import pytest
from app.domain.pnl import WacPnlResult, calculate_realized_pnl from app.domain.pnl import WacPnlResult, calculate_current_wac, calculate_realized_pnl
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
@@ -255,6 +258,149 @@ class TestSymbolFilter:
assert len(result.items) == 2 assert len(result.items) == 2
# ---------------------------------------------------------------------------
# 매수 수수료 WAC 편입 — 단위 테스트 (2026-06-26 변경)
# ---------------------------------------------------------------------------
class TestBuyFeeInCostBasis:
"""buy fee를 WAC cost basis에 편입하는 공식 검증.
공식: running_wac = (running_wac*running_qty + price*qty + buy_fee) / new_total_qty
"""
def test_single_buy_with_fee_realized_pnl_957(self) -> None:
"""기준 케이스: buy 10@1000 fee=5, sell 10@1100 fee=5 tax=33.
편입 WAC = (1000*10 + 5)/10 = 1000.5
realized = (1100 - 1000.5)*10 - 5 - 33 = 995 - 38 = 957
"""
rows = [
_row("buy", 10, 1000, fee=5.0, executed_at=_T0),
_row("sell", 10, 1100, fee=5.0, tax=33.0, executed_at=_T1),
]
result = calculate_realized_pnl(rows)
assert len(result.items) == 1
assert result.items[0].cost_basis_per_unit == pytest.approx(1000.5)
assert result.items[0].realized_pnl == pytest.approx(957.0)
assert result.total_realized_pnl == pytest.approx(957.0)
def test_single_buy_without_fee_is_unchanged(self) -> None:
"""buy fee=0이면 기존 공식과 동일 결과."""
rows = [
_row("buy", 100, 10_000, fee=0.0, executed_at=_T0),
_row("buy", 100, 12_000, fee=0.0, executed_at=_T1),
_row("sell", 50, 15_000, fee=500, tax=200, executed_at=_T2),
]
result = calculate_realized_pnl(rows)
assert result.items[0].cost_basis_per_unit == pytest.approx(11_000.0)
assert result.total_realized_pnl == pytest.approx(199_300.0)
def test_multi_buy_fee_weighted_average(self) -> None:
"""다회 매수 시 fee가 WAC에 가중평균으로 반영된다.
buy 100@10,000 fee=1,000 → WAC_1 = (0 + 10000*100 + 1000) / 100 = 10010.0
buy 100@12,000 fee=1,200 → WAC_2 = (10010*100 + 12000*100 + 1200) / 200
= (1001000 + 1200000 + 1200) / 200
= 2202200 / 200 = 11011.0
sell 50@15,000 fee=500 tax=200
realized = (15000 - 11011)*50 - 500 - 200 = 199450 - 700 = 198750
"""
rows = [
_row("buy", 100, 10_000, fee=1_000.0, executed_at=_T0),
_row("buy", 100, 12_000, fee=1_200.0, executed_at=_T1),
_row("sell", 50, 15_000, fee=500.0, tax=200.0, executed_at=_T2),
]
result = calculate_realized_pnl(rows)
assert len(result.items) == 1
assert result.items[0].cost_basis_per_unit == pytest.approx(11_011.0)
assert result.total_realized_pnl == pytest.approx(198_750.0)
def test_buy_fee_in_cost_basis_no_warning(self) -> None:
"""매수 fee 편입 시에도 이력 완전한 경우 warning 없음."""
rows = [
_row("buy", 10, 1000, fee=5.0, executed_at=_T0),
_row("sell", 10, 1100, fee=5.0, tax=33.0, executed_at=_T1),
]
result = calculate_realized_pnl(rows)
assert result.data_completeness_warning is None
def test_partial_sell_cost_basis_per_unit_reflects_buy_fee(self) -> None:
"""부분 매도 후 cost_basis_per_unit이 매수 fee 포함 WAC를 반영한다.
buy 100@10,000 fee=500 → WAC = (10000*100 + 500)/100 = 10005.0
sell 30@12,000 fee=100 tax=50
realized = (12000 - 10005)*30 - 100 - 50 = 59850 - 150 = 59700
"""
rows = [
_row("buy", 100, 10_000, fee=500.0, executed_at=_T0),
_row("sell", 30, 12_000, fee=100.0, tax=50.0, executed_at=_T1),
]
result = calculate_realized_pnl(rows)
assert result.items[0].cost_basis_per_unit == pytest.approx(10_005.0)
assert result.items[0].realized_pnl == pytest.approx(59_700.0)
class TestCurrentWacBuyFeeInCostBasis:
"""calculate_current_wac도 매수 fee를 WAC에 편입한다."""
def test_buy_only_with_fee(self) -> None:
"""buy 10@1000 fee=5 → current_wac = 1000.5."""
from datetime import datetime, timezone
rows = [
{"side": "buy", "quantity": 10, "price": 1000, "fee_amount": 5.0,
"executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)},
]
result = calculate_current_wac(rows)
assert result.current_quantity == pytest.approx(10.0)
assert result.current_wac == pytest.approx(1000.5)
def test_buy_only_no_fee_unchanged(self) -> None:
"""buy fee=0이면 calculate_current_wac 결과 변화 없음."""
from datetime import datetime, timezone
rows = [
{"side": "buy", "quantity": 100, "price": 10_000, "fee_amount": 0.0,
"executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)},
{"side": "buy", "quantity": 100, "price": 12_000, "fee_amount": 0.0,
"executed_at": datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc)},
]
result = calculate_current_wac(rows)
assert result.current_wac == pytest.approx(11_000.0)
def test_multi_buy_with_fee_current_wac(self) -> None:
"""다회 매수 fee가 current_wac에 가중평균 반영된다.
buy 100@10,000 fee=1,000 → WAC = 10010.0
buy 100@12,000 fee=1,200 → WAC = 11011.0
sell 150주 → remaining=50, WAC 변화 없음 = 11011.0
"""
from datetime import datetime, timezone
rows = [
{"side": "buy", "quantity": 100, "price": 10_000, "fee_amount": 1_000.0,
"executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)},
{"side": "buy", "quantity": 100, "price": 12_000, "fee_amount": 1_200.0,
"executed_at": datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc)},
{"side": "sell", "quantity": 150, "price": 13_000, "fee_amount": 0.0,
"executed_at": datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc)},
]
result = calculate_current_wac(rows)
assert result.current_quantity == pytest.approx(50.0)
assert result.current_wac == pytest.approx(11_011.0)
def test_current_wac_missing_fee_amount_key_defaults_to_zero(self) -> None:
"""fee_amount 키가 없으면 0으로 폴백 — 기존 동작 호환."""
from datetime import datetime, timezone
rows = [
{"side": "buy", "quantity": 100, "price": 10_000,
"executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)},
]
result = calculate_current_wac(rows)
assert result.current_wac == pytest.approx(10_000.0)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REQ-041 AC-7: no psycopg import in domain module (import-time check) # REQ-041 AC-7: no psycopg import in domain module (import-time check)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------