feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)

dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:

- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
  - _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
  - GET/POST/DELETE /v1/portfolios/{id}/tags
  - POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
  - POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
  - v1_ledger.py 제거, disabled 차단(409) 추가
  - test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
  test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
@@ -0,0 +1,42 @@
# BAT-011 Design
- Status: confirmed
- Batch: BAT-011
## Architecture Summary
- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 서브 라우트를 추가한다. 기존 `app/api/v1_ledger.py`의 daemon 인가 3단계 + idempotency 로직을 신 path로 이식하고, disabled 차단을 daemon 인가 이전에 삽입한다.
- 구 path는 파일 전체 제거(`app/api/v1_ledger.py` 삭제) + `app/main.py`에서 include 제거로 처리한다. 별칭/redirect 없음.
- trex-basic 동시 배포 순서: trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포. rollback 시 portfolio-hub + trex-basic 동반 rollback.
## Changed Areas
- Docs:
- `docs/interface-contract.md` — REQ-032에서 이미 "pending REQ-029" 마킹됨. 본 batch에서 실제 명세 반영.
- `docs/runtime-flows.md` — ledger trade path 이전 흐름 + daemon assignment 3단계 인가 갱신 (REQ-032 기반 기존 흐름 보강).
- Modules:
- `app/api/v1_portfolios.py` — 신 path 라우트 추가
- `app/api/v1_ledger.py` — 제거 (구 path 라우트 삭제)
- `app/main.py` — 구 ledger 라우터 include 제거
- `app/services/ledger_service.py` — disabled 차단 검증 추가 (또는 라우터 계층에서 처리)
- Interfaces:
- `POST /v1/portfolios/{portfolio_id}/ledger/trades` (신설)
- `POST /v1/assets/{asset_id}/ledger/trades` (제거 → HTTP 404)
## Key Decisions
1. **구 `v1_ledger.py` 완전 제거**: 라우트만 제거하는 대신 파일 전체를 삭제해 잔재 코드가 남지 않도록 한다. `app/main.py`에서 해당 라우터 include를 제거.
2. **disabled 차단 위치**: 라우터 계층에서 `_get_portfolio_or_404` 직후, daemon 인가 3단계 이전에 `if portfolio.status == "disabled": raise HTTPException(409)` 삽입. 서비스 계층 변경 최소화.
3. **인가 순서**: (0) Bearer 없음 → 401, (1) token `daemon_id IS NULL` → 403, (2) portfolio not found → 404, (3) `portfolio.status == "disabled"` → 409, (4) `portfolio.managed_by_daemon_id IS NULL` → 403, (5) `portfolio.managed_by_daemon_id != auth.daemon_id` → 403, (6) idempotency 분기 → 201/200/409.
4. **trex-basic 동시 배포**: release plan에 배포 순서 잠금 명시. 한시적 별칭/redirect 없음.
## Edge Cases
- `portfolio_id`가 비존재 → HTTP 404 (daemon 인가보다 우선).
- `status='disabled'` portfolio → HTTP 409 (daemon 인가 체크 전에 차단하여 daemon 미배정 오류와 구분).
- 동일 `(portfolio_id, order_id, execution_id)` + 동일 payload 재호출 → HTTP 200 + `status="duplicate"`.
- 동일 key + 다른 payload → HTTP 409 (idempotency conflict).
- Bearer token 없음 → HTTP 401.
## Architecture Impact
- runtime-flows
## Reference Doc Update Plan
- project-structure.md: `v1_ledger.py` 제거 반영
- runtime-flows.md: ledger path 이전 흐름 + daemon 인가 3단계 + disabled 차단 순서 갱신
@@ -0,0 +1,36 @@
# BAT-011 Implementation
- Status: done
- Batch: BAT-011
## Plan Summary
- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 추가 (daemon 인가 3단계 + disabled 차단 + idempotency).
- `app/api/v1_ledger.py` 제거 + `app/main.py` include 정리.
- `tests/test_v1_portfolios_ledger.py` 신규 (AC-1~10).
- `tests/test_v1.py` 구 path 케이스 갱신 + HTTP 404 회귀 확인.
## Changed Files
- `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 신설; `_require_service_token` 의존성(service token + daemon_id IS NULL 검증); `_payloads_match` 헬퍼; `LedgerTradeRequest/Response` import 추가
- `app/api/v1_ledger.py` — **삭제** (구 path 라우트 완전 제거)
- `app/main.py` — `v1_ledger_router` import 및 `app.include_router(v1_ledger_router)` 제거
- `tests/test_v1_portfolios_ledger.py` — **신규** (AC-1~10: 10 tests)
- `tests/test_v1.py` — 구 path 케이스 5건 신 path 갱신; `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; 파일 말미 중복 helper 2건 제거
- `tests/test_daemon_assignment.py` — `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; URL 4건 갱신; `_portfolio_with_daemon`에 `p.status = "enabled"` 추가
## Execution Log
1. `app/api/v1_portfolios.py` — `Response` import 추가, `LedgerTradeRequest/LedgerTradeResponse` import 추가
2. `_require_service_token` 의존성 추가 (service token 검증 + daemon_id IS NULL 403)
3. `_payloads_match` 헬퍼 추가 (idempotency conflict 판정)
4. `POST /{portfolio_id}/ledger/trades` 라우트 추가 (disabled 409 → managed_by_daemon_id NULL 403 → daemon mismatch 403 → insert_ledger_trade)
5. `app/api/v1_ledger.py` 삭제
6. `app/main.py` 에서 v1_ledger 라우터 import + include 제거
7. `tests/test_v1.py` — 구 path URL 및 패치 경로 갱신 + 중복 helper 제거
8. `tests/test_daemon_assignment.py` — URL 및 패치 경로 갱신; `_portfolio_with_daemon` `status` 명시
9. `tests/test_v1_portfolios_ledger.py` — 신규 생성 (AC-1~10)
## Validation
- `pytest tests/test_v1_portfolios_ledger.py -v`: 10 passed
- `pytest --tb=short -q` (full suite): **340 passed, 0 failed** (2 warnings, non-blocking)
## Remaining Risks
- R-1 (High): trex-basic 동시 배포 — release 단계 배포 순서 잠금 필수 (trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포).
@@ -0,0 +1,35 @@
# BAT-011: v1 portfolios ledger path migration
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-003
- Included REQ: REQ-029
- Excluded REQ: none
## Summary
- 기존 `POST /v1/assets/{asset_id}/ledger/trades`(`app/api/v1_ledger.py`)를 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전하고 구 path를 동일 release에서 제거한다.
- 동작·인가·idempotency는 그대로 보존하며 REQ-027 정합의 disabled portfolio 차단(HTTP 409)을 신규 추가한다.
- trex-basic 클라이언트와의 동시 배포가 필수이므로 release plan에 배포 순서 잠금·rollback 전제를 명시한다.
## Scope
- In Scope:
- `app/api/v1_portfolios.py` — `POST /v1/portfolios/{portfolio_id}/ledger/trades` 라우트 추가
- `app/api/v1_ledger.py` — 구 path 라우트 제거 (또는 파일 전체 제거 후 `app/main.py`에서 include 제거)
- `app/main.py` — 구 ledger 라우터 include 제거
- `app/services/portfolio_service.py` 또는 `app/services/ledger_service.py` — disabled 차단 검증 추가
- `tests/test_v1.py` — 구 path 케이스 갱신(HTTP 404 확인)
- `tests/test_v1_portfolios_ledger.py` (신규) — AC-1~10 검증
- Out of Scope:
- `ledger_trades.asset_id` 컬럼명 변경 (별도 migration REQ로 분리 가능)
- trex-basic 코드 변경 (별도 레포)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙, 별칭/redirect 없음. release plan에 배포 순서(trex-basic 사전 빌드 → portfolio-hub 배포 → trex-basic 배포) + rollback 시 trex-basic 동반 rollback 전제 명시 필수.
- R-2 (Low): disabled portfolio 차단(AC-7)은 REQ-027 soft delete 정책 정합 검증으로 신규. 기존 daemon 인가 3단계와 순서 충돌 없이 disabled 체크를 insert 전에 추가.
- 선행 완료: BAT-009(soft delete + disabled 상태 관리, REQ-027) ✅
@@ -0,0 +1,39 @@
# BAT-011 Planning
- Status: confirmed
- Batch: BAT-011
- Profile: standard
## Included REQ
- REQ-029: `POST /v1/portfolios/{portfolio_id}/ledger/trades` 이전 + 구 path 제거 + disabled 차단 추가
## Delivery Plan
1. `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 추가: daemon 인가 3단계 + disabled 차단 + idempotency + conflict 분기
2. `app/api/v1_ledger.py` — 구 `POST /v1/assets/{asset_id}/ledger/trades` 라우트 제거 (또는 파일 전체 제거)
3. `app/main.py` — 구 `v1_ledger` 라우터 include 제거 (v1_portfolios_router는 BAT-009에서 이미 include됨)
4. `app/services/ledger_service.py` 또는 `app/services/portfolio_service.py` — `disabled` 차단 검증 추가
5. `tests/test_v1_portfolios_ledger.py` 신규 — AC-1~10 검증: 신 path 정상 흐름 + daemon 인가 3단계 + idempotency/conflict + disabled + 404 + 401
6. `tests/test_v1.py` 갱신 — 구 path 케이스를 신 path로 갱신 + 구 path HTTP 404 회귀 확인
## Dependencies
- Internal:
- BAT-009 (release-candidate): soft delete(`status='disabled'`) + `managed_by_daemon_id` 관리 확정 ✅
- BAT-006 (release-candidate): service token 분기 정책 확정(REQ-016/017/018) ✅
- External:
- trex-basic 클라이언트: 신 path `POST /v1/portfolios/{portfolio_id}/ledger/trades` 사전 빌드 필요. release 단계에서 배포 순서 잠금 확인 필수.
## Design Gate
- Design Required Before Implementation: yes
- Reason: (1) 구 `v1_ledger.py` 파일 완전 제거 vs. 라우트만 제거 결정 필요, (2) disabled 차단을 서비스 계층과 라우터 계층 중 어디에 위치시킬지 결정 필요, (3) trex-basic 동시 배포 전략(배포 순서 잠금·rollback 계획)을 design 단계에서 명문화.
## Milestones
1. design.md confirmed — 파일 제거 전략, disabled 차단 위치, 배포 순서 잠금 결정
2. 신 path 라우트 구현 완료 (`app/api/v1_portfolios.py`)
3. 구 path 제거 + `app/main.py` 갱신 완료
4. `tests/test_v1_portfolios_ledger.py` AC-1~10 전부 pass
5. `tests/test_v1.py` 구 path HTTP 404 회귀 확인 + 전체 suite pass
## Risks
- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙으로 별칭/redirect 없음. trex-basic이 구 path를 사용 중인 상태에서 portfolio-hub만 배포되면 trex-basic이 즉시 장애. 완화: release plan에 배포 순서(trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포) 잠금 + rollback 시 trex-basic 동반 rollback 명시.
- R-2 (Low): `ledger_trades.asset_id` 컬럼명은 실제로 portfolio_id 값을 저장하는 혼용 상태가 남으나, 본 REQ에서는 path 명명 정합화만 처리하고 컬럼명 변경은 별도 REQ로 이월.
- R-3 (Low): `tests/test_v1.py`에 구 path 테스트가 얼마나 있는지 design 단계에서 사전 파악 필요.
@@ -0,0 +1,33 @@
# BAT-011 Verification
- Status: verified
- Batch: BAT-011
## Acceptance Mapping
- REQ-029:
- AC-1 (`test_ledger_trade_new`): 신 path HTTP 201 + `status="new"` + `asset_id=_PORTFOLIO_ID` ✅
- AC-2 (`test_ledger_trade_duplicate_same_payload`): 동일 key + 동일 payload → HTTP 200 + `status="duplicate"` ✅
- AC-3 (`test_ledger_trade_conflict_different_payload`): 동일 key + 다른 quantity → HTTP 409 ✅
- AC-4 (`test_ledger_trade_no_daemon_id`): service token `daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-5 (`test_ledger_trade_portfolio_unassigned`): `portfolio.managed_by_daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-6 (`test_ledger_trade_daemon_mismatch`): `managed_by_daemon_id="other-daemon"` vs `auth.daemon_id=_DAEMON_ID` → HTTP 403; `insert_ledger_trade` not called ✅
- AC-7 (`test_ledger_trade_disabled_portfolio`): `status="disabled"` → HTTP 409; `insert_ledger_trade` not called ✅
- AC-8 (`test_ledger_trade_portfolio_not_found`): `repo.get.side_effect=Exception` → HTTP 404 ✅
- AC-9 (`test_old_ledger_path_returns_404`): `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (라우트 제거) ✅
- AC-10 (`test_ledger_trade_no_auth`): Bearer 없음 → HTTP 401 ✅
- Regression (`test_daemon_assignment.py` 4건): daemon assignment 인가 3단계 신 path에서 동작 검증 ✅
## Evidence
- Test:
- `pytest tests/test_v1_portfolios_ledger.py -v`: **10/10 passed**
- `pytest --tb=short -q` (full suite): **340 passed, 0 failed**, 2 warnings (non-blocking DeprecationWarning from anyio)
- 이전 suite 대비 +10 tests (330 → 340)
- Manual:
- 해당 없음 — 자동화 검증으로 충분 (인터페이스 변경, UI 없음)
- Logs:
- 2 DeprecationWarnings: `anyio._backends._asyncio.py` `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT`. 기존 suite에서도 동일 경고 존재. 비차단.
## Result
- Ready for Release: true
- Blocking Issues: 없음 (코드 구현 완료, 10/10 AC 검증)
- Release Note: trex-basic 동시 배포 전제 — release 단계에서 배포 순서 잠금 필수. 신 path 단일 서빙, 별칭/redirect 없음.