feat: add portfolio item symbol to v1 responses

This commit is contained in:
2026-05-24 13:09:37 +09:00
parent 3a78e5c138
commit 136ae16885
17 changed files with 655 additions and 11 deletions
+1
View File
@@ -54,6 +54,7 @@ def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem:
return PortfolioItem( return PortfolioItem(
portfolio_id=portfolio.portfolio_id, portfolio_id=portfolio.portfolio_id,
asset_id=portfolio.asset_id, asset_id=portfolio.asset_id,
symbol=portfolio.symbol,
owner_user_id=portfolio.owner_user_id, owner_user_id=portfolio.owner_user_id,
broker=portfolio.broker, broker=portfolio.broker,
status=portfolio.status, status=portfolio.status,
+1
View File
@@ -165,6 +165,7 @@ class PortfolioUpdateRequest(BaseModel):
class PortfolioItem(BaseModel): class PortfolioItem(BaseModel):
portfolio_id: str portfolio_id: str
asset_id: str | None = None asset_id: str | None = None
symbol: str | None = None
owner_user_id: str | None = None owner_user_id: str | None = None
broker: str broker: str
status: str status: str
@@ -0,0 +1,46 @@
# BAT-014 Implementation
- Status: completed
- Batch: BAT-014
## Plan Summary
- planning.md Delivery Plan에 따라 (1) `PortfolioItem` schema에 `symbol` 추가 → (2) `_portfolio_to_item()` serialization 보강 → (3) CRUD/list 테스트 expectation 갱신 → (4) `docs/interface-contract.md` 계약 반영 → (5) pytest 검증 순서로 구현 완료.
- 사용자가 명시한 핵심 요구대로 `GET /v1/portfolios` 는 **asset join 결과를 응답**해야 하므로, repository shape(`_SELECT_PORTFOLIO` + `_row_to_portfolio`)를 유지한 채 응답 schema/문서/테스트를 정합화했다.
## Changed Files
- `app/schemas/v1.py`
- `PortfolioItem` 에 `symbol: str | None = None` 필드 추가
- `app/api/v1_portfolios.py`
- `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 추가
- `tests/test_v1_portfolios_list.py`
- GET `/v1/portfolios` 응답의 `symbol == "005930"` 검증 추가
- 기존 `asset_id` 검증 케이스에도 `symbol` assertion 추가
- `tests/test_v1_portfolios_crud.py`
- POST/PATCH/DELETE 성공 응답에서 `symbol == "005930"` 검증 추가
- `docs/interface-contract.md`
- POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 필드 반영
- GET `/v1/portfolios` 가 server-side `portfolios JOIN assets` 결과를 직렬화한다는 설명 추가
## Execution Log
1. BAT-014 planning/index와 REQ-025/026/027/028/032를 읽고 구현 범위를 확정했다.
2. `app/schemas/v1.py` 에 `PortfolioItem.symbol` 필드를 추가했다.
3. `app/api/v1_portfolios.py` `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 매핑을 추가했다.
4. `tests/test_v1_portfolios_crud.py` 의 POST/PATCH/DELETE 성공 케이스에 `symbol == "005930"` assertion을 추가했다.
5. `tests/test_v1_portfolios_list.py` 의 service-token list 케이스와 `asset_id` 검증 케이스에 `symbol == "005930"` assertion을 추가했다.
6. `docs/interface-contract.md` 의 POST/PATCH/DELETE/GET `/v1/portfolios` 명세를 갱신해 `symbol` 과 join 기반 응답 계약을 명문화했다.
7. `app/repositories/postgres.py` 를 재검토해 `_row_to_portfolio()` 가 `symbol=row[4]` 를 매핑하고, `get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 가 모두 `_SELECT_PORTFOLIO` 기반 join 결과를 사용함을 확인했다.
8. `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` 실행 → **32 passed in 2.13s**.
9. `./.venv/bin/python -m pytest -q` 전체 회귀 실행 → **371 passed in 11.00s**.
## Validation
- Targeted regression:
- `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` → **32 passed in 2.13s**
- Full regression:
- `./.venv/bin/python -m pytest -q` → **371 passed in 11.00s**
- Repository join evidence:
- `_row_to_portfolio()` 가 `symbol=row[4]` 를 `Portfolio.symbol` 로 매핑
- `get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 가 `_SELECT_PORTFOLIO` join 결과를 재사용
## Remaining Risks
- R1 (Low): 이번 batch의 symbol 회귀는 FastAPI/TestClient + fake repo 중심이다. 다만 repository 코드 inspection으로 실제 Postgres 경로도 `assets.symbol` join 결과를 응답까지 유지함을 확인했다.
- R2 (Negligible): pytest 출력에 기존 `HTTP_422_UNPROCESSABLE_ENTITY` deprecation warning 2~3건이 남아 있으나 BAT-014 범위와 직접 관련은 없고 기능 실패는 없다.
@@ -0,0 +1,33 @@
# BAT-014: PortfolioItem symbol 응답 계약 보강 (REQ-025, REQ-026, REQ-027, REQ-028, REQ-032)
- Status: release-candidate
- Profile: batch-lite
- Source Discovery: dcy-003
- Included REQ: REQ-025, REQ-026, REQ-027, REQ-028, REQ-032
- Excluded REQ: REQ-029, REQ-030, REQ-031
## Summary
- trex-orbit 팀 이슈(`docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md`) 후속으로, `PortfolioItem` 기반 v1 portfolio 응답 계약에 `symbol` 필드를 추가한다.
- 특히 `GET /v1/portfolios` 는 클라이언트 캐시 복원이 아니라 **`portfolios JOIN assets` 결과를 그대로 응답**해야 하며, `symbol` 은 linked asset row의 `assets.symbol` 이어야 한다.
- POST/PATCH/DELETE `/v1/portfolios` 응답도 동일한 `PortfolioItem` contract를 따르도록 정합성을 맞추고, baseline 문서(`docs/interface-contract.md`)에 이를 반영한다.
## Scope
- In Scope:
- `app/schemas/v1.py` — `PortfolioItem.symbol: str | None = None` 추가
- `app/api/v1_portfolios.py` — `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 반영
- `app/repositories/postgres.py` — GET/list/create/update/delete 응답이 asset join 결과를 유지하는지 검증
- `tests/test_v1_portfolios_list.py` — GET `/v1/portfolios` 응답 `symbol` 검증 추가
- `tests/test_v1_portfolios_crud.py` — POST/PATCH/DELETE 응답 `symbol` 검증 추가
- `docs/interface-contract.md` — `PortfolioItem` 응답 예시/설명에 `symbol` 및 GET join 기반 응답 설명 추가
- Out of Scope:
- portfolio write semantics 변경 (ownership, soft delete, disabled 보호 정책 자체는 불변)
- `/v1/portfolios/{id}/ledger/trades`, `/tags`, `/comments` 계약 변경
- assets catalog schema 또는 DB migration
## Documents
- Planning: [planning.md](./planning.md)
## Notes
- profile 선정 근거: follow-up change는 기존 Implemented REQ의 응답 contract augmentation이며 범위가 좁고 additive 성격이 강해 `batch-lite` 로 시작한다.
- 다만 `docs/interface-contract.md` 갱신은 필수이므로 문서 정합성까지 한 배치에서 닫는다.
- BAT-009 / BAT-010 verification evidence는 본 batch 완료 후 superseded 표기가 필요할 수 있다.
@@ -0,0 +1,50 @@
# BAT-014 Planning
- Status: draft
- Batch: BAT-014
- Profile: batch-lite
## Included REQ
- REQ-025: POST /v1/portfolios 응답에 `PortfolioItem.symbol` 포함
- REQ-026: PATCH /v1/portfolios/{portfolio_id} 응답에 `PortfolioItem.symbol` 포함
- REQ-027: DELETE /v1/portfolios/{portfolio_id} (HTTP 200 응답) 에 `PortfolioItem.symbol` 포함
- REQ-028: GET /v1/portfolios 응답이 `portfolios JOIN assets` 결과를 기반으로 `symbol` 을 직접 반환
- REQ-032: baseline 문서에 위 계약 반영
## Delivery Plan
1. `app/schemas/v1.py` 의 `PortfolioItem` 에 `symbol: str | None = None` 추가
2. `app/api/v1_portfolios.py` 의 `_portfolio_to_item()` 에 `symbol=portfolio.symbol` 추가
3. `app/repositories/postgres.py` 의 `_SELECT_PORTFOLIO` / `_row_to_portfolio` / `get()` / `list_portfolios()` 흐름을 재확인해 GET `/v1/portfolios` 가 asset join 결과를 응답까지 유지하는지 검증
4. `tests/test_v1_portfolios_list.py` 에서 GET 응답 item의 `symbol` 존재 및 기대값(`005930`) 검증 추가
5. `tests/test_v1_portfolios_crud.py` 에서 POST/PATCH/DELETE 응답의 `symbol` 존재 및 기대값 검증 추가
6. `docs/interface-contract.md` 에 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명의 `symbol` 반영 및 GET의 asset join 기반 응답 설명 추가
7. 회귀 테스트 실행 및 결과를 후속 verification 단계에 기록
## Dependencies
- Internal:
- BAT-009 (v1 portfolio CRUD core) — 기존 구현과 테스트 기반
- BAT-010 (portfolio v1 baseline docs) — interface-contract baseline 업데이트 trace
- External:
- 없음
## Design Gate
- Design Required Before Implementation: **no**
- Reason:
- 변경은 기존 CRUD/list 응답 contract에 additive field 하나를 추가하고 문서/테스트를 보강하는 수준이다.
- `app/repositories/postgres.py` 는 이미 `_SELECT_PORTFOLIO` 에서 `JOIN assets a ON p.asset_id = a.asset_id` 및 `a.symbol` 선택을 수행하고 있어, 새 쿼리 전략이나 구조 재설계가 필요하지 않다.
- 사용자가 명시한 핵심 제약(`GET /v1/portfolios` 는 asset join 결과를 응답해야 함)도 현재 repository shape와 정합한다. 따라서 design 산출물 없이 구현/검증으로 바로 진행 가능하다.
## Milestones
1. Schema/API serialization 반영
2. CRUD/list 테스트 보강
3. baseline 문서 갱신
4. 회귀 테스트 실행
5. verification 근거 정리 후 REQ 재Implemented 검토
## Risks
- **R1 (Low)**: 테스트 fixture가 in-memory fake repo를 사용하므로 repository-level join 보장을 직접 증명하지 못할 수 있음.
- **대응**: GET contract 문서와 repository 코드 확인 결과를 verification에 함께 기록하고, 필요 시 repo integration test 추가를 검토.
- **R2 (Low)**: `PortfolioItem` 에 `symbol` 추가 시 기존 스냅샷/응답 dict equality 테스트가 깨질 수 있음.
- **대응**: 테스트 expectation을 additive field 기준으로 갱신.
- **R3 (Low)**: baseline 문서가 POST/PATCH/DELETE/GET 중 일부 응답만 갱신되어 source of truth 불일치가 남을 수 있음.
- **대응**: `docs/interface-contract.md` 네 경로를 한 턴에 모두 갱신.
@@ -0,0 +1,79 @@
# BAT-014 Verification
- Status: confirmed
- Batch: BAT-014
## Acceptance Mapping
### REQ-025: POST /v1/portfolios
- AC-1 (admin + user_id → target owner): `tests/test_v1_portfolios_crud.py::test_post_admin_creates_for_target_user` PASSED — `owner_user_id == _OTHER_USER_ID`.
- AC-2 (session/env token → own user/fallback): `tests/test_v1_portfolios_crud.py::test_post_user_creates_for_self` PASSED.
- AC-3 (service token → 403): `tests/test_v1_portfolios_crud.py::test_post_service_token_returns_403` PASSED.
- AC-5 (inactive/missing asset → 422): `tests/test_v1_portfolios_crud.py::test_post_inactive_asset_returns_422`, `tests/test_v1_portfolios_crud.py::test_post_missing_asset_returns_422` PASSED.
- AC-6 (no token → 401): `tests/test_v1_portfolios_crud.py::test_post_no_bearer_returns_401` PASSED.
- AC-7 (non-admin body user_id ignored): `tests/test_v1_portfolios_crud.py::test_post_non_admin_user_id_ignored` PASSED.
- AC-8 (POST 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_post_admin_creates_for_target_user` PASSED — response body `symbol == "005930"`.
### REQ-026: PATCH /v1/portfolios/{portfolio_id}
- AC-1 (admin → any portfolio): `tests/test_v1_portfolios_crud.py::test_patch_admin_updates_any_portfolio` PASSED — `broker == "ebest"`.
- AC-2 (session token + own portfolio → 200): `tests/test_v1_portfolios_crud.py::test_patch_user_updates_own_portfolio` PASSED.
- AC-3 (session token + other portfolio → 403): `tests/test_v1_portfolios_crud.py::test_patch_user_other_portfolio_returns_403` PASSED.
- AC-4 (service token → 403): `tests/test_v1_portfolios_crud.py::test_patch_service_token_returns_403` PASSED.
- AC-6 (no token → 401): `tests/test_v1_portfolios_crud.py::test_patch_no_bearer_returns_401` PASSED.
- AC-7 (non-existent → 404): `tests/test_v1_portfolios_crud.py::test_patch_nonexistent_portfolio_returns_404` PASSED.
- AC-8 (disabled + non-status field → 409): `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_non_status_returns_409` PASSED.
- AC-9 (disabled + status=enabled → 200): `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_status_restore_returns_200` PASSED.
- AC-10 (PATCH 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_patch_admin_updates_any_portfolio`, `tests/test_v1_portfolios_crud.py::test_patch_disabled_portfolio_status_restore_returns_200` PASSED — both assert `symbol == "005930"`.
### REQ-027: DELETE /v1/portfolios/{portfolio_id}
- AC-1 (session + own → 200, disabled, daemon_id NULL): `tests/test_v1_portfolios_crud.py::test_delete_user_own_active_portfolio_soft_deletes` PASSED.
- AC-3 (admin + other user → 200): `tests/test_v1_portfolios_crud.py::test_delete_admin_other_user_portfolio` PASSED.
- AC-4 (session + other → 403): `tests/test_v1_portfolios_crud.py::test_delete_user_other_portfolio_returns_403` PASSED.
- AC-5 (service token → 403): `tests/test_v1_portfolios_crud.py::test_delete_service_token_returns_403` PASSED.
- AC-7 (no token → 401): `tests/test_v1_portfolios_crud.py::test_delete_no_bearer_returns_401` PASSED.
- AC-8 (non-existent → 404): `tests/test_v1_portfolios_crud.py::test_delete_nonexistent_portfolio_returns_404` PASSED.
- AC-11 (DELETE 200 응답 `symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_crud.py::test_delete_user_own_active_portfolio_soft_deletes`, `tests/test_v1_portfolios_crud.py::test_delete_admin_other_user_portfolio` PASSED — both assert `symbol == "005930"`.
### REQ-028: GET /v1/portfolios
- AC-1 (service + daemon_id → own daemon portfolios): `tests/test_v1_portfolios_list.py::test_get_service_token_daemon_bound_returns_own_portfolios` PASSED.
- AC-2 (service no daemon → 403): `tests/test_v1_portfolios_list.py::test_get_service_token_no_daemon_returns_403` PASSED.
- AC-3 (session → own portfolios): `tests/test_v1_portfolios_list.py::test_get_user_token_returns_own_portfolios` PASSED.
- AC-4 (admin no ?user_id → default branch): `tests/test_v1_portfolios_list.py::test_get_admin_default_returns_own_portfolios` PASSED.
- AC-5 (admin + ?user_id → target): `tests/test_v1_portfolios_list.py::test_get_admin_with_user_id_returns_target_portfolios` PASSED.
- AC-7 (disabled excluded by default): `tests/test_v1_portfolios_list.py::test_get_excludes_disabled_by_default` PASSED.
- AC-8 (`include_disabled=true` → all): `tests/test_v1_portfolios_list.py::test_get_include_disabled_returns_all` PASSED.
- AC-9 (client token → 403): `tests/test_v1_portfolios_list.py::test_get_client_token_returns_403` PASSED.
- AC-10 (no token → 401): `tests/test_v1_portfolios_list.py::test_get_no_bearer_returns_401` PASSED.
- AC-11 (`asset_id` response field): `tests/test_v1_portfolios_list.py::test_get_response_has_asset_id_field` PASSED.
- AC-12 (`symbol` == joined `assets.symbol`): `tests/test_v1_portfolios_list.py::test_get_service_token_daemon_bound_returns_own_portfolios`, `tests/test_v1_portfolios_list.py::test_get_response_has_asset_id_field` PASSED — both assert `symbol == "005930"`.
### REQ-032: portfolio v1 baseline 문서 갱신
- AC-1 (`docs/interface-contract.md` 에 v1 portfolio route entries 존재): 문서 유지 + POST/PATCH/DELETE/GET `/v1/portfolios` 섹션 갱신 완료.
- AC-6 (사람 리뷰 기준 baseline 충돌 없음): 이번 batch 범위에서 `docs/interface-contract.md` 의 portfolio response contract를 코드와 정합화했다.
- AC-7 (`symbol` 필드 + GET join 설명): `docs/interface-contract.md` 에서 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 추가, GET 섹션에 `portfolios JOIN assets` 기반 직렬화 설명 추가 완료.
## Evidence
- Test:
- `./.venv/bin/python -m pytest tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_list.py -q` → **32 passed in 2.13s**
- `./.venv/bin/python -m pytest -q` → **371 passed in 11.00s**
- Manual / code inspection:
- `app/repositories/postgres.py::_row_to_portfolio()` 는 `symbol=row[4]` 를 `Portfolio.symbol` 로 매핑한다.
- `app/repositories/postgres.py::get()` / `create_portfolio()` / `update_portfolio()` / `soft_delete_portfolio()` / `list_portfolios()` 는 `_SELECT_PORTFOLIO` 기반 join 결과를 재사용한다.
- 따라서 GET 뿐 아니라 POST/PATCH/DELETE 응답도 linked asset row의 `assets.symbol` 을 유지한다.
- Logs:
- pytest 출력은 `implementation.md` Execution Log 8~9 항목 참조.
## Architecture Impact Sync
- Profile이 `batch-lite` 이고 planning.md 의 Design Gate는 `no` 다.
- 실제 구현은 schema/API/test/docs contract augmentation에 한정되며 신규 쿼리 전략/모듈/마이그레이션이 없다.
- `app/repositories/postgres.py` 의 기존 join shape를 재사용했으므로 **no architecture impact confirmed**.
- Baseline docs sync는 REQ-032 범위에 맞춰 `docs/interface-contract.md` 갱신으로 충족했다. `docs/project-structure.md` / `docs/runtime-flows.md` / `docs/data-model.md` 는 이번 변경으로 추가 수정이 필요한 구조 변화가 없다.
## Result
- Ready for Release: true
- Blocking Issues:
- 없음 (본 batch 범위 내 AC evidence 확보)
- Follow-up:
- BAT-009 verification의 REQ-025/026/027/028 response-contract evidence는 BAT-014 기준으로 superseded 처리.
- BAT-010 verification의 REQ-032 interface-contract evidence는 BAT-014 기준으로 superseded 처리.
- 기존 422 deprecation warning(`HTTP_422_UNPROCESSABLE_ENTITY`) 정리는 별도 housekeeping 성격의 후속 change로 분리 가능.
+1
View File
@@ -21,3 +21,4 @@
| BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) | | BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) |
| BAT-012 | standard | release-candidate | REQ-002, REQ-010 | dcy-001, dcy-002, dcy-003 | [bat-012](./bat-012_20260521_assets-tagging-and-admin-get-redefinition/) | | BAT-012 | standard | release-candidate | REQ-002, REQ-010 | dcy-001, dcy-002, dcy-003 | [bat-012](./bat-012_20260521_assets-tagging-and-admin-get-redefinition/) |
| BAT-013 | batch-lite | release-candidate | REQ-005 | dcy-002 | [bat-013](./bat-013_20260521_asset-tag-apply-remove-fix/) | | BAT-013 | batch-lite | release-candidate | REQ-005 | dcy-002 | [bat-013](./bat-013_20260521_asset-tag-apply-remove-fix/) |
| BAT-014 | batch-lite | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028, REQ-032 | dcy-003 | [bat-014](./bat-014_20260524_portfolioitem-symbol-response-contract/) |
+8 -4
View File
@@ -300,21 +300,24 @@
- Auth: any valid token; service/client → HTTP 403 - Auth: any valid token; service/client → HTTP 403
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}` - Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
- Response: HTTP 201 `PortfolioItem` - Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", "owner_user_id": "<str|null>", "broker": "<str>", "status": "<str>", "name": "<str>", ...}`
- Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction.
- Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token - Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}` ### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
- Auth: any valid token; service/client → HTTP 403; ownership required - Auth: any valid token; service/client → HTTP 403; ownership required
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}` - Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- Response: HTTP 200 `PortfolioItem` - Response: HTTP 200 `PortfolioItem {"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", ...}`
- Note: response `symbol` is emitted from the joined asset row referenced by `asset_id`.
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided - Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete) ### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete)
- Auth: any valid token; service/client → HTTP 403; ownership required - Auth: any valid token; service/client → HTTP 403; ownership required
- Body: none - Body: none
- Response: HTTP 200 `PortfolioItem` with `status="disabled"`, `managed_by_daemon_id=null` - Response: HTTP 200 `PortfolioItem` with `status="disabled"`, `managed_by_daemon_id=null`, `symbol="<assets.symbol|null>"`
- Note: soft delete does not break the asset join; `symbol` remains the linked `assets.symbol` value in the HTTP 200 response.
- Dependent data: portfolio_comments and tag_ids are preserved on soft delete; only status and daemon assignment change - Dependent data: portfolio_comments and tag_ids are preserved on soft delete; only status and daemon assignment change
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if not found - Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if not found
@@ -325,7 +328,8 @@
- Branching by token type: - Branching by token type:
- `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403 - `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403
- `user/admin`: returns owner's own portfolios; admin may override with `?user_id=<id>` - `user/admin`: returns owner's own portfolios; admin may override with `?user_id=<id>`
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [...], "total": <int>}` - Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", ...}], "total": <int>}`
- Note: list items are serialized from the server-side `portfolios JOIN assets` result; `symbol` must equal the joined `assets.symbol` for each returned `asset_id`.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags` ### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
@@ -0,0 +1,119 @@
# [REQ] `GET /v1/portfolios` 응답에 종목 코드(`symbol`) 포함
## 요약
`GET /v1/portfolios` 응답의 `PortfolioItem` 에 종목 코드(`symbol`) 필드를 추가해주세요. 현재는 `asset_id` (UUID) 만 반환되어, 종목 코드가 필요한 클라이언트가 별도 카탈로그 조회로 매핑해야 합니다.
## 배경
trex-orbit 자동매매 데몬은 매 사이클마다 `GET /v1/portfolios` 로 daemon-scoped portfolio 리스트를 받아 운용 대상 종목을 결정합니다. 이후 외부 데이터 서비스 (`kis-data-hub`) 에 시세 / 분봉을 요청하려면 종목 코드 (예: `"005930"`) 가 필요한데, 현재 `PortfolioItem` 에는 `asset_id` (UUID) 만 있고 `symbol` 이 빠져 있어 다음 우회가 필요합니다.
1. trex-orbit 부팅 시 asset 카탈로그를 별도로 조회해 `asset_id → symbol` 캐시 구성
2. 사이클 중 `asset_id` 마다 캐시 lookup
3. 캐시 미스 시 portfolio-hub 에 추가 조회
이 과정에서 캐시 stale / 신규 asset 등록 후 동기화 지연 / 클라이언트마다 캐시 로직 중복 같은 운영 부담이 발생합니다.
## 현재 상태
**Endpoint**: `GET /v1/portfolios` ([`app/api/v1_portfolios.py:224`](app/api/v1_portfolios.py#L224))
**응답 스키마**: [`PortfolioItem`](app/schemas/v1.py#L165)
```python
class PortfolioItem(BaseModel):
portfolio_id: str
asset_id: str | None = None # UUID
owner_user_id: str | None = None
broker: str
status: str
name: str
cash_balance: float = 0.0
asset_quantity: float = 0.0
price: float = 0.0
profit_buffer: float = 0.0
tag_ids: list[str] = Field(default_factory=list)
managed_by_daemon_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
# symbol 없음
```
종목 코드는 별도 `AssetItem` 에만 존재 ([`app/schemas/v1.py:55`](app/schemas/v1.py#L55)):
```python
class AssetItem(BaseModel):
asset_id: str
symbol: str # ← 여기 있음
status: str
...
```
## 요청 변경
### 1. 스키마
`PortfolioItem` 에 `symbol` 필드 추가:
```python
class PortfolioItem(BaseModel):
portfolio_id: str
asset_id: str | None = None
symbol: str | None = None # ← 추가 요청
owner_user_id: str | None = None
...
```
### 2. 응답 채움 로직
`GET /v1/portfolios` 의 `_portfolio_to_item` 빌더가 `portfolios.asset_id → assets.symbol` join 결과를 활용해 `symbol` 을 채워주세요. 이미 repository 단계에서 join 되어 있다면 변환 로직만 추가하면 됩니다.
### 3. 단건 / 관련 엔드포인트도 동일 적용
다음 엔드포인트의 응답에도 `symbol` 포함:
- `POST /v1/portfolios` (생성 후 반환)
- `PATCH /v1/portfolios/{portfolio_id}`
- `DELETE /v1/portfolios/{portfolio_id}` (soft delete 응답)
- 기타 `PortfolioItem` 을 반환하는 모든 경로
## 정당성
1. **클라이언트 단순화**: daemon 클라이언트 (trex-orbit 등) 가 portfolio 정보만 받으면 즉시 외부 시세 API 호출 가능. 추가 카탈로그 조회 / 캐시 관리 불필요.
2. **일관성**: `cash_balance`, `asset_quantity`, `price` 등 자산 관련 필드는 이미 PortfolioItem 에 포함됨. `symbol` 도 자산 식별의 핵심이므로 같이 포함되는 것이 자연스러움.
3. **stale cache 위험 제거**: 클라이언트 캐시 + miss fallback 패턴은 카탈로그 변경 시 일시적 불일치 가능. server 측 join 으로 항상 최신 매핑 제공.
4. **운영 부담 감소**: 캐시 무효화 / 갱신 / 재로드 로직이 클라이언트마다 중복 구현되는 것을 회피.
## 호환성
`symbol` 은 신규 필드이며 `Optional[str]` 로 추가하면 기존 클라이언트에 영향 없음 (additive change).
## 영향 범위
- [`app/api/v1_portfolios.py`](app/api/v1_portfolios.py) — `_portfolio_to_item` 에 symbol 채움
- [`app/schemas/v1.py`](app/schemas/v1.py#L165) — `PortfolioItem` 에 `symbol` 필드 추가
- [`app/repositories/`](app/repositories/) — `list_portfolios` / `get_portfolio` 가 asset join 결과를 들고 오도록 보강 (이미 그렇다면 변경 없음)
- [`docs/interface-contract.md`](docs/interface-contract.md#L321) — `GET /v1/portfolios` 응답 예시 갱신
- 테스트 — `PortfolioItem` 응답 형식 검증 케이스 추가
## 검수 기준
1. `GET /v1/portfolios` 호출 시 각 item 에 `symbol` 필드가 포함됨
2. `symbol` 값은 해당 `asset_id` 의 `AssetItem.symbol` 과 일치 (server side join)
3. 단건 조회 / 생성 / 수정 / 삭제 응답도 동일하게 `symbol` 포함
4. `interface-contract.md` 의 `GET /v1/portfolios` 응답 예시에 `symbol` 명시
5. 기존 테스트 회귀 없음 + 신규 케이스 추가
## 우선순위
**Medium** — trex-orbit 사이드에서 캐시 기반 우회가 가능해 즉시 차단 이슈는 아니지만, 운영 본격 시작 전 정리하면 lifecycle 비용을 크게 줄임.
## 관련 문서
- trex-orbit `temp/plan.md` 8.4.1 step 3 (asset_id → symbol resolve) — 본 이슈의 우회 구현 단계
- trex-orbit `temp/plan.md` 8.6.4.1 전제 시점 — universe 결정 과정에서 symbol resolve 가 필요한 이유
## 작성자 / 일자
- 요청자: trex-orbit 팀 (genichin@gmail.com)
- 작성일: 2026-05-24
@@ -13,6 +13,7 @@
- `docs/interface-contract.md` 갱신: - `docs/interface-contract.md` 갱신:
- `/v1/portfolios*` 라우트 명세 추가: POST(REQ-025), PATCH(REQ-026), DELETE(REQ-027), GET(REQ-028), `/ledger/trades`(REQ-029), `/tags`(REQ-030), `/comments`(REQ-031) - `/v1/portfolios*` 라우트 명세 추가: POST(REQ-025), PATCH(REQ-026), DELETE(REQ-027), GET(REQ-028), `/ledger/trades`(REQ-029), `/tags`(REQ-030), `/comments`(REQ-031)
- 각 라우트의 토큰 분기 규칙(`token_type ∈ {service, client}` 거부 + `permission` 기준 분기) 일관 명시 - 각 라우트의 토큰 분기 규칙(`token_type ∈ {service, client}` 거부 + `permission` 기준 분기) 일관 명시
- `PortfolioItem` 응답 예시에 `symbol` 필드를 포함하고, 특히 `GET /v1/portfolios` 는 `portfolios JOIN assets` 기반 응답으로 `assets.symbol` 을 노출한다는 점을 명시
- 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 + 동일 batch release 정책 명시 - 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 + 동일 batch release 정책 명시
- `GET /v1/assets`는 dcy-001 FR-2 admin catalog 조회 전용으로 의미 복원 (REQ-013 Deprecated 정합) - `GET /v1/assets`는 dcy-001 FR-2 admin catalog 조회 전용으로 의미 복원 (REQ-013 Deprecated 정합)
- daemon authorization model 갱신 (3단계 fail-closed) - daemon authorization model 갱신 (3단계 fail-closed)
@@ -36,6 +37,7 @@
4. Given `grep -n "soft delete\|status='disabled'\|disabled" docs/data-model.md`, When 본 REQ 구현 후, Then Portfolio.status='disabled' 의미와 의존 데이터 보존 정책이 반영된다. 4. Given `grep -n "soft delete\|status='disabled'\|disabled" docs/data-model.md`, When 본 REQ 구현 후, Then Portfolio.status='disabled' 의미와 의존 데이터 보존 정책이 반영된다.
5. Given `grep -n "v1_portfolios.py" docs/project-structure.md`, When 본 REQ 구현 후, Then 신규 라우터 파일이 명시된다. 5. Given `grep -n "v1_portfolios.py" docs/project-structure.md`, When 본 REQ 구현 후, Then 신규 라우터 파일이 명시된다.
6. Given 사람 리뷰, When baseline 4종 문서를 점검, Then REQ-025~031의 v1 portfolio mutation 정책과 충돌하는 잔재가 없다. 6. Given 사람 리뷰, When baseline 4종 문서를 점검, Then REQ-025~031의 v1 portfolio mutation 정책과 충돌하는 잔재가 없다.
7. Given `grep -n "symbol" docs/interface-contract.md`, When 본 REQ 구현 후, Then `PortfolioItem` 기반 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명에 `symbol` 필드가 명시되고 GET 항목은 joined asset result 설명을 포함한다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -57,6 +59,64 @@
## Change Log ## Change Log
### CHG-20260524-02
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-014 verification 완료. `docs/interface-contract.md` 에 portfolio CRUD/list 응답의 `symbol` 필드와 GET join 기반 응답 설명이 반영되어 REQ-032 augmentation 범위가 충족되었다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-014 (release-candidate); BAT-010 interface-contract evidence superseded by BAT-014
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: release 단계 전 추가 baseline drift 점검
### CHG-20260524-01
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- portfolio v1 baseline 문서에 `PortfolioItem.symbol` 및 GET `/v1/portfolios` asset-join 응답 계약 반영이 필요해 REQ-032를 augment 하고 상태를 Implemented → Approved로 되돌림.
#### Intent
- trex-orbit 팀 요청으로 CRUD/list portfolio 응답 계약이 `symbol` 포함 형태로 확장되었다. baseline 문서가 이를 반영하지 않으면 구현 후에도 API source of truth가 stale 해지므로, interface-contract/runtime 설명의 후속 갱신을 별도 delivery trace로 강제한다.
#### Acceptance Criteria Delta
- Added:
- AC-7: baseline 문서에 `symbol` 필드 및 GET join 기반 응답 설명이 존재해야 함
#### Impacted Area Delta
- Docs: `docs/interface-contract.md` (필수), 필요 시 `docs/runtime-flows.md` 의 GET `/v1/portfolios` 설명 보강
- Modules: 없음
- Tests: 없음 (문서 grep + 사람 리뷰)
#### Delivery Trace Delta
- Batches: BAT-010 evidence partially superseded; follow-up BAT-014 예정
- Releases: REL-001 baseline evidence 일부 재검토 필요
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (baseline documentation evidence partial)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-014에서 interface-contract 예시와 설명 갱신 후 재검증
#### Validation Plan
- `docs/interface-contract.md` 에서 POST/PATCH/DELETE/GET `/v1/portfolios` 응답 설명과 예시에 `symbol` 이 추가됐는지, GET 섹션에 joined asset result 설명이 있는지 grep + 사람 리뷰로 확인한다.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -20,7 +20,8 @@
- **body 스키마** (`PortfolioCreateRequest` 신규): - **body 스키마** (`PortfolioCreateRequest` 신규):
- 필수: `asset_id`, `broker` - 필수: `asset_id`, `broker`
- 선택: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`(admin 분기 전용) - 선택: `name`, `cash_balance`, `asset_quantity`, `asset_price`, `profit_buffer`, `strategy_name`, `strategy_params`, `managed_by_daemon_id`, `user_id`(admin 분기 전용)
- 응답: HTTP 201 + 생성된 portfolio의 `PortfolioItem`(`portfolio_id`, `asset_id`, `owner_user_id`, ...). - 응답: HTTP 201 + 생성된 portfolio의 `PortfolioItem`(`portfolio_id`, `asset_id`, `symbol`, `owner_user_id`, ...).
- 응답의 `symbol` 값은 생성된 `portfolios.asset_id`가 가리키는 `assets.symbol`을 기준으로 server-side join 결과에서 채운다. 클라이언트가 별도 catalog lookup으로 복원하는 방식은 본 REQ 범위의 정식 계약이 아니다.
- catalog 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 본 REQ 구현 시점에 결정. dcy-003 R-5 트레이드오프 reference 참조. - catalog 검증 위치(서비스 계층 공통 진입점 vs v1 라우터 레이어 한정)는 본 REQ 구현 시점에 결정. dcy-003 R-5 트레이드오프 reference 참조.
## Acceptance Criteria ## Acceptance Criteria
@@ -31,6 +32,7 @@
5. Given body `asset_id`가 catalog에 미존재 또는 `status='inactive'`, When `POST /v1/portfolios`, Then HTTP 422 또는 403로 거부되고 portfolio row가 생성되지 않는다. 5. Given body `asset_id`가 catalog에 미존재 또는 `status='inactive'`, When `POST /v1/portfolios`, Then HTTP 422 또는 403로 거부되고 portfolio row가 생성되지 않는다.
6. Given Bearer token 없음, When `POST /v1/portfolios`, Then HTTP 401. 6. Given Bearer token 없음, When `POST /v1/portfolios`, Then HTTP 401.
7. Given non-admin token + body `user_id` 지정, When `POST /v1/portfolios`, Then `user_id`는 무시되어 `owner_user_id == auth.user_id`로 생성되거나 HTTP 422. 7. Given non-admin token + body `user_id` 지정, When `POST /v1/portfolios`, Then `user_id`는 무시되어 `owner_user_id == auth.user_id`로 생성되거나 HTTP 422.
8. Given active catalog asset(`asset_id=<uuid>`, `symbol="005930"`), When `POST /v1/portfolios`, Then HTTP 201 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 해당 asset row join 결과와 일치한다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -54,6 +56,64 @@
## Change Log ## Change Log
### CHG-20260524-02
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-014 verification 완료. `PortfolioItem.symbol` 응답 계약이 코드, 테스트, baseline 문서에 반영되었고 REQ-025 acceptance criteria 전부가 trace 가능해졌다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: release 단계에서 smoke 확인
### CHG-20260524-01
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-025 생성 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림.
#### Intent
- trex-orbit 팀 이슈(`docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md`) 후속. 생성 응답만 `asset_id`를 반환하고 `symbol`을 생략하면 클라이언트가 별도 asset catalog 조회/캐시를 유지해야 하므로, 생성 직후 응답에서도 joined asset의 `symbol`을 즉시 반환하도록 계약을 확장한다.
#### Acceptance Criteria Delta
- Added:
- AC-8: POST 응답의 `PortfolioItem.symbol` 이 `assets.symbol` join 결과와 일치해야 함
#### Impacted Area Delta
- Docs: `docs/interface-contract.md` 의 POST `/v1/portfolios` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속)
- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`), `app/repositories/postgres.py:get()` join 응답 확인
- Tests: `tests/test_v1_portfolios_crud.py` 에 POST 응답 `symbol` 검증 추가 필요
#### Delivery Trace Delta
- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정
- Releases: REL-001 response-contract evidence 일부 재검토 필요
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (response schema evidence partial)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-014에서 POST/PATCH/DELETE/GET portfolio 응답 `symbol` 회귀 검증 추가
#### Validation Plan
- `tests/test_v1_portfolios_crud.py` 에서 POST 201 응답 body의 `symbol` 존재 및 `asset_id`가 가리키는 asset row의 `symbol` 일치 여부를 검증한다.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -20,6 +20,7 @@
- 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위: `broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등. - 수정 가능 필드는 기존 `UpdatePortfolioRequest` 범위: `broker`, `status`, `name`, `symbol`, `strategy_name`, `strategy_params`, `asset_price`, `managed_by_daemon_id` 등.
- **disabled portfolio 보호** (REQ-027 soft delete 정책 정합): `portfolios.status == 'disabled'`인 portfolio에 대해 status 전이(`'enabled'`로 복구) 외 다른 필드 변경 시 HTTP 409 또는 422로 거부. - **disabled portfolio 보호** (REQ-027 soft delete 정책 정합): `portfolios.status == 'disabled'`인 portfolio에 대해 status 전이(`'enabled'`로 복구) 외 다른 필드 변경 시 HTTP 409 또는 422로 거부.
- 응답: HTTP 200 + 갱신된 `PortfolioItem`. - 응답: HTTP 200 + 갱신된 `PortfolioItem`.
- 응답의 `symbol` 값은 수정 대상 portfolio가 참조하는 `assets.symbol`을 기준으로 server-side join 결과에서 채운다.
## Acceptance Criteria ## Acceptance Criteria
1. Given admin token + 본인 또는 타 user portfolio, When `PATCH /v1/portfolios/{id}` body `{"strategy_name": "..."}`, Then HTTP 200 + 갱신된 portfolio 반환. 1. Given admin token + 본인 또는 타 user portfolio, When `PATCH /v1/portfolios/{id}` body `{"strategy_name": "..."}`, Then HTTP 200 + 갱신된 portfolio 반환.
@@ -31,6 +32,7 @@
7. Given 비존재 `portfolio_id`, When `PATCH /v1/portfolios/{id}`, Then HTTP 404. 7. Given 비존재 `portfolio_id`, When `PATCH /v1/portfolios/{id}`, Then HTTP 404.
8. Given `status='disabled'` portfolio + body `{"strategy_name": "..."}`(status 외 필드), When `PATCH`, Then HTTP 409 또는 422 (disabled write 차단). 8. Given `status='disabled'` portfolio + body `{"strategy_name": "..."}`(status 외 필드), When `PATCH`, Then HTTP 409 또는 422 (disabled write 차단).
9. Given `status='disabled'` portfolio + body `{"status": "enabled"}` (admin), When `PATCH`, Then HTTP 200 + status 복구. 9. Given `status='disabled'` portfolio + body `{"status": "enabled"}` (admin), When `PATCH`, Then HTTP 200 + status 복구.
10. Given portfolio가 active asset(`symbol="005930"`)을 참조, When `PATCH /v1/portfolios/{id}` 성공, Then HTTP 200 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 joined asset row와 일치한다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -51,6 +53,64 @@
## Change Log ## Change Log
### CHG-20260524-02
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-014 verification 완료. PATCH 성공 응답의 `PortfolioItem.symbol` 계약이 코드/테스트/문서에 반영되어 REQ-026 acceptance criteria 전부가 다시 충족되었다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: release 단계에서 smoke 확인
### CHG-20260524-01
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-026 수정 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림.
#### Intent
- trex-orbit 팀이 portfolio mutation 후에도 추가 catalog lookup 없이 즉시 symbol을 사용해야 하는 요구를 제기했다. PATCH 응답이 기존 portfolio state만 되돌리고 joined asset 식별자(`symbol`)를 생략하면 CRUD 응답 contract가 비대칭이므로, 수정 응답에도 동일 필드를 포함하도록 정의를 확장한다.
#### Acceptance Criteria Delta
- Added:
- AC-10: PATCH 성공 응답의 `PortfolioItem.symbol` 이 joined `assets.symbol` 과 일치해야 함
#### Impacted Area Delta
- Docs: `docs/interface-contract.md` 의 PATCH `/v1/portfolios/{id}` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속)
- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`)
- Tests: `tests/test_v1_portfolios_crud.py` 에 PATCH 응답 `symbol` 검증 추가 필요
#### Delivery Trace Delta
- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정
- Releases: REL-001 response-contract evidence 일부 재검토 필요
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (response schema evidence partial)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-014에서 PATCH 응답 contract 회귀 검증 추가
#### Validation Plan
- `tests/test_v1_portfolios_crud.py` 에서 PATCH 200 응답 body의 `symbol` 존재 및 joined asset symbol 일치 여부를 검증한다.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -29,6 +29,7 @@
- 비존재 `portfolio_id`는 HTTP 404. ownership 불일치는 HTTP 403. - 비존재 `portfolio_id`는 HTTP 404. ownership 불일치는 HTTP 403.
- 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — 본 REQ 구현 시 확정). - 이미 `disabled` 상태인 portfolio에 재호출 시 HTTP 200 + 멱등 응답(또는 HTTP 409 — 본 REQ 구현 시 확정).
- 응답: HTTP 200 또는 204 + (HTTP 200 시) disabled 상태 portfolio 또는 확정 응답. - 응답: HTTP 200 또는 204 + (HTTP 200 시) disabled 상태 portfolio 또는 확정 응답.
- HTTP 200 응답을 반환하는 구현에서는 `PortfolioItem.symbol` 도 함께 포함해야 하며, 값은 soft delete 이후에도 linked asset row join 결과와 동일해야 한다.
## Acceptance Criteria ## Acceptance Criteria
1. Given session token + 본인 active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태. 1. Given session token + 본인 active portfolio, When `DELETE /v1/portfolios/{id}`, Then HTTP 200/204 + `portfolios.status='disabled'` + `managed_by_daemon_id IS NULL` 사후 상태.
@@ -41,6 +42,7 @@
8. Given 비존재 `portfolio_id`, When `DELETE /v1/portfolios/{id}`, Then HTTP 404. 8. Given 비존재 `portfolio_id`, When `DELETE /v1/portfolios/{id}`, Then HTTP 404.
9. Given disabled portfolio + admin `PATCH status='enabled'`, When `PATCH`, Then HTTP 200 + 복구. (REQ-026 AC-9와 연계) 9. Given disabled portfolio + admin `PATCH status='enabled'`, When `PATCH`, Then HTTP 200 + 복구. (REQ-026 AC-9와 연계)
10. Given AC-1 실행 후 disabled portfolio에 ledger trade/tag/comment write 시도, When 시도, Then 모두 차단(HTTP 409 또는 422). (REQ-029/030/031과 연계 검증) 10. Given AC-1 실행 후 disabled portfolio에 ledger trade/tag/comment write 시도, When 시도, Then 모두 차단(HTTP 409 또는 422). (REQ-029/030/031과 연계 검증)
11. Given active asset(`symbol="005930"`)을 참조하던 portfolio, When `DELETE /v1/portfolios/{id}` 가 HTTP 200 응답을 반환, Then 응답의 `PortfolioItem.symbol == "005930"` 이고 값은 joined asset row와 일치한다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -62,6 +64,64 @@
## Change Log ## Change Log
### CHG-20260524-02
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-014 verification 완료. DELETE(soft delete) HTTP 200 응답의 `PortfolioItem.symbol` 계약이 코드/테스트/문서에 반영되어 REQ-027 acceptance criteria 전부가 다시 충족되었다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: release 단계에서 smoke 확인
### CHG-20260524-01
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- `PortfolioItem.symbol` 응답 계약 추가에 따라 REQ-027 soft delete 응답 contract를 보강하고 상태를 Implemented → Approved로 되돌림.
#### Intent
- trex-orbit 팀 이슈 후속으로, delete/disable 응답에서도 client가 별도 asset lookup 없이 포트폴리오 식별 symbol을 유지할 수 있어야 한다. soft delete는 asset reference를 제거하지 않으므로 HTTP 200 응답이 있는 경우 `symbol`을 함께 반환하는 계약이 자연스럽다.
#### Acceptance Criteria Delta
- Added:
- AC-11: DELETE(HTTP 200) 응답의 `PortfolioItem.symbol` 이 joined `assets.symbol` 과 일치해야 함
#### Impacted Area Delta
- Docs: `docs/interface-contract.md` 의 DELETE `/v1/portfolios/{id}` 응답 예시에 `symbol` 추가 필요 (REQ-032 후속)
- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`)
- Tests: `tests/test_v1_portfolios_crud.py` 에 DELETE 200 응답 `symbol` 검증 추가 필요
#### Delivery Trace Delta
- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정
- Releases: REL-001 response-contract evidence 일부 재검토 필요
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (response schema evidence partial)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-014에서 DELETE 응답 contract 회귀 검증 추가
#### Validation Plan
- `tests/test_v1_portfolios_crud.py` 에서 DELETE 200 응답 body의 `symbol` 존재 및 joined asset symbol 일치 여부를 검증한다.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -19,7 +19,8 @@
- `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환. - `permission ∈ {read, trade-write}` → `owner_user_id = auth.user_id` 필터, 본인 portfolio만 반환.
- **`?market=<code>` 필터**: 모든 토큰 타입에서 지원. `<code> = ANY(assets.markets)`인 portfolio만 반환(catalog와 join). - **`?market=<code>` 필터**: 모든 토큰 타입에서 지원. `<code> = ANY(assets.markets)`인 portfolio만 반환(catalog와 join).
- **`?include_disabled=true` 옵션** (dcy-003 R-2 완화): 기본 응답은 `status='disabled'`인 portfolio 제외. opt-in 시에만 포함. - **`?include_disabled=true` 옵션** (dcy-003 R-2 완화): 기본 응답은 `status='disabled'`인 portfolio 제외. opt-in 시에만 포함.
- 응답 항목 schema: 신규 `PortfolioItem` (`portfolio_id`, `asset_id`, `owner_user_id`, `broker`, `status`, `cash_balance`, ..., `tag_ids`, `managed_by_daemon_id`, ...). `asset_id`는 catalog의 `assets.asset_id`(UUID) 참조. - 응답 항목 schema: 신규 `PortfolioItem` (`portfolio_id`, `asset_id`, `symbol`, `owner_user_id`, `broker`, `status`, `cash_balance`, ..., `tag_ids`, `managed_by_daemon_id`, ...). `asset_id`는 catalog의 `assets.asset_id`(UUID) 참조.
- `GET /v1/portfolios` 의 각 item은 반드시 `portfolios JOIN assets` 결과를 기반으로 직렬화되어야 한다. 특히 `symbol` 은 linked asset row의 `assets.symbol` 을 그대로 노출해야 하며, 클라이언트 캐시나 후속 catalog lookup으로 복원하는 모델은 정식 응답 계약이 아니다.
- 응답: HTTP 200 + `PortfolioListResponse` (`portfolios: list[PortfolioItem]`, `total: int`). - 응답: HTTP 200 + `PortfolioListResponse` (`portfolios: list[PortfolioItem]`, `total: int`).
## Acceptance Criteria ## Acceptance Criteria
@@ -34,6 +35,7 @@
9. Given client token, When `GET /v1/portfolios`, Then HTTP 403 fail-closed. 9. Given client token, When `GET /v1/portfolios`, Then HTTP 403 fail-closed.
10. Given Bearer token 없음, When `GET /v1/portfolios`, Then HTTP 401. 10. Given Bearer token 없음, When `GET /v1/portfolios`, Then HTTP 401.
11. Given 응답 portfolio 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다. 11. Given 응답 portfolio 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다.
12. Given `GET /v1/portfolios` 응답 item, When `symbol` 필드를 확인, Then 값은 같은 row의 `asset_id`가 가리키는 joined `assets.symbol` 과 일치한다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
@@ -57,6 +59,64 @@
## Change Log ## Change Log
### CHG-20260524-02
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-014 verification 완료. GET `/v1/portfolios` 응답이 `portfolios JOIN assets` 결과에서 `symbol` 을 직접 반환함이 코드/테스트/문서로 재검증되었다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches: BAT-014 (release-candidate); BAT-009 response-contract evidence superseded by BAT-014
- Releases: pending
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: promote-to-implemented
- Follow-up Action: release 단계에서 smoke 확인
### CHG-20260524-01
- Date: 2026-05-24
- Author: Hermes Agent
#### Change Summary
- `GET /v1/portfolios` 응답을 joined asset result 기반으로 명확히 고정하고 `PortfolioItem.symbol` 계약을 추가하기 위해 REQ-028을 augment. 상태를 Implemented → Approved로 되돌림.
#### Intent
- trex-orbit 자동매매 데몬은 `GET /v1/portfolios` 응답만으로 외부 시세 API 호출 대상 symbol을 바로 얻어야 한다. 기존 계약은 `asset_id`만 제공해 클라이언트 측 catalog cache를 강제했으므로, server-side asset join 결과(`assets.symbol`)를 직접 응답에 포함하도록 명시한다.
#### Acceptance Criteria Delta
- Added:
- AC-12: GET 응답 item의 `symbol` 은 joined `assets.symbol` 과 일치해야 함
#### Impacted Area Delta
- Docs: `docs/interface-contract.md` GET `/v1/portfolios` 예시/필드 정의에 `symbol` 및 join 기반 응답 설명 추가 필요 (REQ-032 후속)
- Modules: `app/schemas/v1.py`, `app/api/v1_portfolios.py` (`_portfolio_to_item`), `app/repositories/postgres.py` (`_SELECT_PORTFOLIO` join 결과를 응답까지 전달)
- Tests: `tests/test_v1_portfolios_list.py` 에 list 응답 `symbol` 검증 추가 필요
#### Delivery Trace Delta
- Batches: BAT-009 evidence partially superseded; follow-up BAT-014 예정
- Releases: REL-001 response-contract evidence 일부 재검토 필요
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (response schema evidence partial)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-014에서 GET `/v1/portfolios` 응답이 asset join 결과를 직접 반환하는지 회귀 검증
#### Validation Plan
- `tests/test_v1_portfolios_list.py` 에서 각 item의 `symbol` 필드 존재와 `asset_id`가 가리키는 joined asset row의 `symbol` 일치 여부를 검증한다.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
+2 -2
View File
@@ -116,8 +116,8 @@
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-05-24: REQ-025 / REQ-026 / REQ-027 / REQ-028 / REQ-032 Approved → Implemented (CHG-20260524-02 각각) — BAT-014 verification 완료. `PortfolioItem.symbol` 응답 계약이 코드(`app/schemas/v1.py`, `app/api/v1_portfolios.py`), 테스트(`tests/test_v1_portfolios_crud.py`, `tests/test_v1_portfolios_list.py`), baseline 문서(`docs/interface-contract.md`)에 반영됨. BAT-014는 release-candidate로 승격되었고 BAT-009/BAT-010의 관련 response-contract evidence는 superseded.
- 2026-05-24: REQ-025 / REQ-026 / REQ-027 / REQ-028 / REQ-032 Implemented → Approved (CHG-20260524-01 각각) — trex-orbit 팀 이슈(`docs/issues/20260524_portfolio-hub-ticket-symbol-in-portfolios.md`) 후속. `PortfolioItem.symbol` 응답 계약 추가 및 `GET /v1/portfolios` 의 asset join 기반 응답(`portfolios JOIN assets` → `assets.symbol`)을 명문화. BAT-009 / BAT-010 및 REL-001의 기존 response-contract evidence는 부분 무효화되어 follow-up BAT-014에서 재검증 필요.
- 2026-05-21: REQ-005 Approved → Implemented (CHG-20260521-03) — BAT-013 confirm-batch-verification 완료. `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트 호출 교체 + 회귀 테스트 7건 추가. assets 테이블 mutation 정합화 evidence 확보. 전체 369 passed (BAT-012 baseline 362 → +7). BAT-004의 AC-1/AC-5 evidence는 BAT-013에서 superseded. CLI 동일 mismatch는 follow-up REQ 후보. - 2026-05-21: REQ-005 Approved → Implemented (CHG-20260521-03) — BAT-013 confirm-batch-verification 완료. `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트 호출 교체 + 회귀 테스트 7건 추가. assets 테이블 mutation 정합화 evidence 확보. 전체 369 passed (BAT-012 baseline 362 → +7). BAT-004의 AC-1/AC-5 evidence는 BAT-013에서 superseded. CLI 동일 mismatch는 follow-up REQ 후보.
- 2026-05-21: REQ-005 Implemented → Approved (CHG-20260521-02) — BAT-012 implementation 단계에서 `apply_asset_tag`/`remove_asset_tag` 라우트([app/api/v1_assets.py:175,200](../../app/api/v1_assets.py))가 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 **portfolios 테이블을 대상화**하는 불일치 발견. AC-1/AC-5 의도("assets 테이블의 tag_ids")가 BAT-012 신설 `assets.tag_ids` 컬럼에 반영되지 않음. AC-1/AC-5 명문화, Impacted Area에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드 추가. BAT-004 REQ-005 evidence는 부분 무효, 신규 batch 편입 필요. - 2026-05-21: REQ-005 Implemented → Approved (CHG-20260521-02) — BAT-012 implementation 단계에서 `apply_asset_tag`/`remove_asset_tag` 라우트([app/api/v1_assets.py:175,200](../../app/api/v1_assets.py))가 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 **portfolios 테이블을 대상화**하는 불일치 발견. AC-1/AC-5 의도("assets 테이블의 tag_ids")가 BAT-012 신설 `assets.tag_ids` 컬럼에 반영되지 않음. AC-1/AC-5 명문화, Impacted Area에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드 추가. BAT-004 REQ-005 evidence는 부분 무효, 신규 batch 편입 필요.
- 2026-05-21: REQ-002 / REQ-010 Approved → Implemented (CHG-20260521-03 각각) — BAT-012 confirm-batch-verification 완료. 362 PASSED (32 신규: REQ-002 AC-1~4 8건 + REQ-010 AC-9~12 9건 + AC-1~8 회귀 9건 + 단위 6건). assets.tag_ids 마이그레이션·도메인·repository·schema·GET 라우트 archived 합성 모두 evidence 확보. REQ-010 BAT-002 trace는 superseded by BAT-012로 표기. - 2026-05-21: REQ-002 / REQ-010 Approved → Implemented (CHG-20260521-03 각각) — BAT-012 confirm-batch-verification 완료. 362 PASSED (32 신규: REQ-002 AC-1~4 8건 + REQ-010 AC-9~12 9건 + AC-1~8 회귀 9건 + 단위 6건). assets.tag_ids 마이그레이션·도메인·repository·schema·GET 라우트 archived 합성 모두 evidence 확보. REQ-010 BAT-002 trace는 superseded by BAT-012로 표기.
- 2026-05-21: REQ-002 Implemented → Approved (CHG-20260521-02) — 파일명(`req-002_assets-tag-ids-column.md`)·dcy-002 FR-2와 본문 정합성 회복. 기존 본문(portfolios.tag_ids)은 dcy-001 분리 이전 portfolios==asset 시절 흔적으로 REQ-003과 중복이었음. 새 범위(assets.tag_ids + `idx_assets_tag_ids_gin`)는 미구현 — BAT-003 verification은 REQ-002 범위 무효화. REQ-009 Impacted Area에 cross-reference 추가(CHG-20260521-02, Implemented 유지). 신규 batch 편입 필요.
- 2026-05-21: REQ-010 Implemented → Approved (CHG-20260521-02) — REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. dcy-003 옵션 B(REQ-013 Deprecation) 후속의 GET admin catalog 재정의를 본 REQ에 augmentation. AC-9~12 신설, app/api/v1_assets.py GET 라우트 재정의 + AssetCatalogListResponse 추가. BAT-002 verification 재실행 필요.
+11 -3
View File
@@ -198,6 +198,7 @@ def test_post_admin_creates_for_target_user() -> None:
assert data["owner_user_id"] == _OTHER_USER_ID assert data["owner_user_id"] == _OTHER_USER_ID
assert data["broker"] == "kiwoom" assert data["broker"] == "kiwoom"
assert data["status"] == "enabled" assert data["status"] == "enabled"
assert data["symbol"] == "005930"
def test_post_user_creates_for_self() -> None: def test_post_user_creates_for_self() -> None:
@@ -302,7 +303,9 @@ def test_patch_admin_updates_any_portfolio() -> None:
headers=_auth("admin-tok"), headers=_auth("admin-tok"),
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["broker"] == "ebest" data = resp.json()
assert data["broker"] == "ebest"
assert data["symbol"] == "005930"
def test_patch_user_updates_own_portfolio() -> None: def test_patch_user_updates_own_portfolio() -> None:
@@ -402,7 +405,9 @@ def test_patch_disabled_portfolio_status_restore_returns_200() -> None:
headers=_auth("admin-tok"), headers=_auth("admin-tok"),
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "enabled" data = resp.json()
assert data["status"] == "enabled"
assert data["symbol"] == "005930"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -424,6 +429,7 @@ def test_delete_user_own_active_portfolio_soft_deletes() -> None:
data = resp.json() data = resp.json()
assert data["status"] == "disabled" assert data["status"] == "disabled"
assert data["managed_by_daemon_id"] is None assert data["managed_by_daemon_id"] is None
assert data["symbol"] == "005930"
def test_delete_admin_other_user_portfolio() -> None: def test_delete_admin_other_user_portfolio() -> None:
@@ -434,7 +440,9 @@ def test_delete_admin_other_user_portfolio() -> None:
with _with_repo(repo): with _with_repo(repo):
resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok")) resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok"))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "disabled" data = resp.json()
assert data["status"] == "disabled"
assert data["symbol"] == "005930"
def test_delete_user_other_portfolio_returns_403() -> None: def test_delete_user_other_portfolio_returns_403() -> None:
+2
View File
@@ -142,6 +142,7 @@ def test_get_service_token_daemon_bound_returns_own_portfolios() -> None:
data = resp.json() data = resp.json()
assert data["total"] == 1 assert data["total"] == 1
assert data["portfolios"][0]["portfolio_id"] == "p-001" assert data["portfolios"][0]["portfolio_id"] == "p-001"
assert data["portfolios"][0]["symbol"] == "005930"
def test_get_service_token_no_daemon_returns_403() -> None: def test_get_service_token_no_daemon_returns_403() -> None:
@@ -272,3 +273,4 @@ def test_get_response_has_asset_id_field() -> None:
assert resp.status_code == 200 assert resp.status_code == 200
item = resp.json()["portfolios"][0] item = resp.json()["portfolios"][0]
assert item["asset_id"] == _ASSET_UUID assert item["asset_id"] == _ASSET_UUID
assert item["symbol"] == "005930"