feat(req-002): implement portfolio tagging and tag filters
This commit is contained in:
@@ -54,6 +54,15 @@
|
||||
- `POST /portfolios/{portfolio_id}/transfer`
|
||||
- `POST /portfolios/{portfolio_id}/reset`
|
||||
- `DELETE /portfolios/{portfolio_id}`
|
||||
- `POST /portfolios/{portfolio_id}/tags`
|
||||
- `GET /portfolios/{portfolio_id}/tags`
|
||||
- `DELETE /portfolios/{portfolio_id}/tags`
|
||||
|
||||
REQ-002 정렬 메모:
|
||||
|
||||
- 포트폴리오 목록 조회는 반복 query parameter `includeTags`, `excludeTags`, `matchMode`를 지원한다.
|
||||
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 구현한다.
|
||||
- CLI는 `portfolio tag add|list|remove` 및 `portfolio list --include-tag/--exclude-tag/--match-mode` 계약을 제공한다.
|
||||
|
||||
### 포트폴리오 댓글 (`REQ-001`)
|
||||
- `POST /portfolios/{portfolio_id}/comments`
|
||||
@@ -106,12 +115,17 @@
|
||||
- Query:
|
||||
- `broker: string | null`
|
||||
- `status_filter: string | null`
|
||||
- `includeTags: string[] | null`
|
||||
- `excludeTags: string[] | null`
|
||||
- `matchMode: "AND" | "OR" | null` (`includeTags`에만 적용)
|
||||
- 응답 `200`: `PortfolioListItem[]`
|
||||
- 오류:
|
||||
- `400`: `matchMode`가 `AND` 또는 `OR`가 아님
|
||||
|
||||
예시:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
|
||||
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled&includeTags=grade:A&excludeTags=blacklist:trade&matchMode=OR" \
|
||||
-H "Authorization: Bearer <READ_TOKEN>"
|
||||
```
|
||||
|
||||
@@ -123,6 +137,7 @@ curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled
|
||||
"status": "enabled",
|
||||
"name": "Bitcoin",
|
||||
"symbol": "KRW-BTC",
|
||||
"tags": ["grade:A"],
|
||||
"cash_balance": 1000000.0,
|
||||
"asset_quantity": 0.1,
|
||||
"price": 100000000.0,
|
||||
@@ -157,6 +172,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
|
||||
"status": "enabled",
|
||||
"name": "Bitcoin",
|
||||
"symbol": "KRW-BTC",
|
||||
"tags": ["grade:A"],
|
||||
"owner_user_id": "user-1",
|
||||
"cash_balance": 1000000.0,
|
||||
"asset_quantity": 0.1,
|
||||
@@ -246,6 +262,16 @@ curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \
|
||||
- `POST /portfolios/{portfolio_id}/transfer`: `TransferRequest` -> `PortfolioDetailResponse`
|
||||
- `POST /portfolios/{portfolio_id}/reset`: `ResetPortfolioRequest` -> `PortfolioDetailResponse`
|
||||
- `DELETE /portfolios/{portfolio_id}`: admin only, query `keep_ledger: bool = false`, 응답 `204`
|
||||
- `POST /portfolios/{portfolio_id}/tags`: `PortfolioTagMutationRequest` -> `PortfolioTagResponse`
|
||||
- `GET /portfolios/{portfolio_id}/tags`: `PortfolioTagResponse[]`
|
||||
- `DELETE /portfolios/{portfolio_id}/tags`: body `PortfolioTagMutationRequest`, 응답 `204`
|
||||
|
||||
태그 엔드포인트 추가 규칙:
|
||||
|
||||
- `POST`/`DELETE`는 `trade-write`, `GET`은 `read`
|
||||
- 비관리자 요청은 기존 ownership 검사를 동일하게 적용
|
||||
- 입력 `tag`는 앞뒤 공백을 제거한 값을 저장/비교 기준으로 사용
|
||||
- 정규화 후 빈 문자열은 `422`, 중복 태그 추가는 `409`, 포트폴리오 또는 태그 미존재는 `404`
|
||||
|
||||
대표 요청:
|
||||
|
||||
@@ -277,6 +303,19 @@ curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \
|
||||
|
||||
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>"
|
||||
|
||||
curl -X POST "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag": " grade:A "}'
|
||||
|
||||
curl -X GET "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <READ_TOKEN>"
|
||||
|
||||
curl -X DELETE "http://localhost:8000/portfolios/krw-btc/tags" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag": "grade:A"}'
|
||||
```
|
||||
|
||||
#### 포트폴리오 댓글 (`REQ-001`)
|
||||
@@ -703,7 +742,7 @@ curl -X POST "http://localhost:8000/users" \
|
||||
|
||||
## 문서 동기화 규칙
|
||||
- `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다.
|
||||
- `REQ-002` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다.
|
||||
- `REQ-002` 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다.
|
||||
|
||||
## Open Implementation Points
|
||||
- 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| `settings-and-env-loading.md` | `.env`/환경변수 로딩, 필수 키, CORS/운영 설정 규칙 | N/A |
|
||||
| `architecture-overview.md` | 시스템 개요, 모듈 책임, 런타임 흐름, 테스트/호환성 기준 | `REQ-003` |
|
||||
| `api-contracts.md` | HTTP API 상세 계약, request/response 모델, 대표 payload 예시 | `REQ-001`, `REQ-002`, `REQ-003` |
|
||||
| `portfolio-tagging-and-search-design.md` | 태그 정규화, API/CLI 검색 파라미터, 모듈 책임, 구현 단계 설계 | `REQ-002` |
|
||||
| `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` |
|
||||
| `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A |
|
||||
| `portfolio-ownership-and-auth-design.md` | 포트폴리오 소유주(`owner_user_id`) 도입, 소유권 접근 제어, 검증 시나리오 설계 | `REQ-004` |
|
||||
@@ -30,7 +31,7 @@
|
||||
| REQ ID | Technical Docs |
|
||||
| --- | --- |
|
||||
| `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-002` | `api-contracts.md` |
|
||||
| `REQ-002` | `api-contracts.md`, `portfolio-tagging-and-search-design.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` |
|
||||
| `REQ-004` | `auth-and-token-lifecycle.md`, `api-contracts.md`, `persistence-and-migrations.md`, `portfolio-ownership-and-auth-design.md` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Persistence and Migrations
|
||||
|
||||
- Related REQ: `REQ-001`, `REQ-003`, `REQ-004`
|
||||
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004`
|
||||
|
||||
## 목표
|
||||
DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다.
|
||||
@@ -257,6 +257,48 @@ Conditional migration behavior:
|
||||
- application layer cascade:
|
||||
- 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다.
|
||||
|
||||
## REQ-002 Tagging Storage Design
|
||||
|
||||
목표:
|
||||
|
||||
- 포트폴리오별 태그 연결을 중복 없이 저장한다.
|
||||
- 앞뒤 공백 제거 기반 정규화와 검색용 include/exclude 조건을 DB 계층에서 일관되게 지원한다.
|
||||
|
||||
구현 저장 모델:
|
||||
|
||||
- 신규 테이블 `portfolio_tags`
|
||||
- 복합 PK(`portfolio_id`, `tag_value`) 사용
|
||||
- `portfolio_id`: 대상 포트폴리오
|
||||
- `tag_value`: 앞뒤 공백 제거 후 저장되는 태그 값
|
||||
- `created_at`: 연결 생성 시각
|
||||
- 동일 포트폴리오 내 중복 태그는 PK 자체로 방지한다.
|
||||
|
||||
정규화 책임:
|
||||
|
||||
- API/CLI 입력 계층에서 앞뒤 공백 제거를 수행한다.
|
||||
- repository는 이미 정규화된 `tag_value`를 기준으로 insert/delete/query를 수행한다.
|
||||
- DB는 `UNIQUE (portfolio_id, tag_value)`로 중복 insert를 방지하고 application은 이를 `409 Conflict`로 매핑한다.
|
||||
|
||||
검색 전략:
|
||||
|
||||
- `includeTags`와 `excludeTags`는 모두 정규화된 태그 값 기준으로 비교한다.
|
||||
- `matchMode=AND`는 `GROUP BY portfolio_id` + `HAVING COUNT(DISTINCT tag_value) = <includeTags 개수>` 전략을 기본안으로 둔다.
|
||||
- `matchMode=OR`는 `WHERE tag_value IN (...)` + distinct portfolio projection을 기본안으로 둔다.
|
||||
- `excludeTags`는 anti-join 또는 `NOT EXISTS` 전략을 사용한다.
|
||||
|
||||
권장 인덱스:
|
||||
|
||||
- `UNIQUE (portfolio_id, tag_value)`
|
||||
- `INDEX (tag_value, portfolio_id)`
|
||||
- owner-scoped 목록 필터와 결합될 수 있으므로 `portfolios.owner_user_id` 인덱스와 함께 실행 계획을 검토한다.
|
||||
|
||||
적용 마이그레이션 단계:
|
||||
|
||||
1. `20260414_0012_portfolio_tags.py`에서 `portfolio_tags` 테이블 생성
|
||||
2. `fk_portfolio_tags_portfolio` FK와 `idx_portfolio_tags_tag_portfolio` 인덱스 생성
|
||||
3. API/CLI/repository가 동일 revision 기준으로 태그 mutation 및 검색을 사용
|
||||
4. 필요 시 exact-match 중심 추가 인덱스를 검토
|
||||
|
||||
## Validation Notes
|
||||
- 마이그레이션은 빈 DB와 기존 DB 모두에서 연속 적용 가능해야 한다.
|
||||
- 파괴적 변경(drop/rename)은 백필 또는 호환 단계 계획 없이 직접 반영하지 않는다.
|
||||
@@ -276,7 +318,9 @@ Conditional migration behavior:
|
||||
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` and backfill from `symbol` |
|
||||
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo history |
|
||||
| 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK |
|
||||
| 20260414_0011 | migrations/versions/20260414_0011_bot_principal_owner_user_id.py | Restore `bot_principals.owner_user_id`, backfill owner, create owner index, and add owner FK |
|
||||
| 20260414_0012 | migrations/versions/20260414_0012_portfolio_tags.py | Add `portfolio_tags`, portfolio FK, and tag lookup index for REQ-002 |
|
||||
|
||||
## Open Implementation Points
|
||||
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
|
||||
- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다.
|
||||
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Portfolio Tagging and Search Design
|
||||
|
||||
- Related REQ: `REQ-002`
|
||||
|
||||
## 목표
|
||||
- 승인된 `REQ-002`를 구현 가능한 내부 설계로 정리한다.
|
||||
- 태그 정규화, 검색 파라미터 의미, 모듈 책임, 저장소/마이그레이션 방향을 한 문서에서 추적한다.
|
||||
|
||||
## 구현 범위
|
||||
- 포트폴리오별 태그 추가/삭제 흐름
|
||||
- HTTP API와 CLI의 공통 태그 검색 파라미터 의미
|
||||
- repository query 전략과 DB 인덱스 후보
|
||||
|
||||
제외:
|
||||
|
||||
- 태그 추천, 자동 분류, 네임스페이스 사전 관리
|
||||
- 부분 일치/전방 일치/full-text 검색
|
||||
|
||||
## 정규화 정책
|
||||
- 입력 태그는 API/CLI 계층에서 앞뒤 공백을 제거한다.
|
||||
- 정규화 후 빈 문자열이면 validation 오류로 처리한다.
|
||||
- 대소문자, 내부 공백, `:` 네임스페이스는 변경하지 않는다.
|
||||
- 중복 판단은 정규화된 값 기준으로 수행한다.
|
||||
|
||||
예시:
|
||||
|
||||
- `" grade:A "` -> `"grade:A"`
|
||||
- `"Grade:A"`와 `"grade:A"`는 서로 다른 태그로 취급한다.
|
||||
- `"blacklist: trade"`의 내부 공백은 보존한다.
|
||||
|
||||
## 모듈 책임
|
||||
|
||||
API:
|
||||
|
||||
- 대상 파일: `app/api/portfolios.py`
|
||||
- 책임:
|
||||
- `POST/GET/DELETE /portfolios/{portfolio_id}/tags` 제공
|
||||
- `GET /portfolios`의 반복 query 파라미터 `includeTags`, `excludeTags`, `matchMode` 수신
|
||||
- 기존 ownership/auth 정책 재사용
|
||||
|
||||
CLI:
|
||||
|
||||
- 대상 파일: `app/cli/commands/portfolio.py`
|
||||
- 책임:
|
||||
- `portfolio tag add|list|remove` 명령 노출
|
||||
- `portfolio list --include-tag --exclude-tag --match-mode` 옵션 제공
|
||||
|
||||
Schema:
|
||||
|
||||
- 대상 파일: `app/schemas/portfolio.py`
|
||||
- 책임:
|
||||
- tag mutation request/response 모델 정의
|
||||
- list query 파라미터 contract 정리
|
||||
|
||||
Service:
|
||||
|
||||
- 대상 파일: `app/services/portfolio_service.py`
|
||||
- 책임:
|
||||
- 정규화된 태그 입력 검증
|
||||
- repository 호출 orchestration
|
||||
- duplicate/not-found/auth 오류를 API/CLI가 소비할 수 있는 에러로 유지
|
||||
|
||||
Repository:
|
||||
|
||||
- 대상 파일: `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`
|
||||
- 책임:
|
||||
- 태그 연결 추가/삭제
|
||||
- include/exclude + matchMode query 제공
|
||||
- duplicate insert를 명시적 오류로 변환
|
||||
|
||||
## 검색 파라미터 의미
|
||||
|
||||
- `includeTags`
|
||||
- 포함해야 하는 정규화 태그 목록
|
||||
- `excludeTags`
|
||||
- 하나라도 일치하면 제외할 정규화 태그 목록
|
||||
- `matchMode`
|
||||
- `includeTags`에만 적용
|
||||
- `AND`: 모든 include tag 필요
|
||||
- `OR`: 하나 이상의 include tag 필요
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. 입력 태그 정규화
|
||||
2. ownership/auth 필터 적용
|
||||
3. include 조건 계산
|
||||
4. exclude 조건 제거
|
||||
5. 중복 portfolio 제거
|
||||
6. 기존 recency ordering과 충돌 여부 확인
|
||||
|
||||
## Validation Notes
|
||||
- duplicate tag add는 `UNIQUE (portfolio_id, tag_value)` 또는 동등한 repository 검증으로 `409`에 매핑해야 한다.
|
||||
- 존재하지 않는 포트폴리오에 대한 add/remove는 `404`로 매핑해야 한다.
|
||||
- 검색 결과는 `OR` 조건에서 중복 없이 한 번만 반환해야 한다.
|
||||
- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.
|
||||
|
||||
## 파일 위치 및 구현 후보
|
||||
- API route extension: `app/api/portfolios.py`
|
||||
- CLI extension: `app/cli/commands/portfolio.py`
|
||||
- schema extension: `app/schemas/portfolio.py`
|
||||
- service orchestration: `app/services/portfolio_service.py`
|
||||
- repository contract: `app/repositories/portfolio_repository.py`
|
||||
- Postgres query implementation: `app/repositories/postgres.py`
|
||||
- migration: `migrations/versions/20260414_0012_portfolio_tags.py`
|
||||
|
||||
## 구현 결정
|
||||
- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 고정했다.
|
||||
- `GET /portfolios` 태그 필터는 반복 query parameter 방식으로 구현했다.
|
||||
- CLI 다중 태그 입력은 반복 옵션 `--include-tag`, `--exclude-tag`로 구현했다.
|
||||
Reference in New Issue
Block a user