# API Contracts - Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004` ## 공통 원칙 - 응답은 JSON 기반으로 반환한다. - 인증이 필요한 엔드포인트는 `Authorization: Bearer `을 사용한다. - 권한 계층은 `read` < `trade-write` < `admin`이다. - `admin`이 아닌 요청은 포트폴리오 리소스 접근 시 `owner_user_id` 소유권 검사를 추가로 통과해야 한다. - 에러 응답은 HTTP 상태 코드와 함께 원인 메시지를 제공한다. - Base URL 예시는 `http://localhost:8000`을 기준으로 한다. - JSON 예시는 대표 형태이며 일부 선택 필드를 생략할 수 있다. ## Contract Surface ### 공개 문서 엔드포인트 - `GET /docs` - `GET /redoc` - `GET /openapi.json` ### 헬스 - `GET /health` (인증 없음) - 정상 응답 예시: ```json {"status": "ok"} ``` ### 인증 - `POST /auth/login` - `GET /auth/tokens` - `POST /auth/tokens` - `DELETE /auth/tokens/{token_id}` - `POST /auth/bots` - `GET /auth/bots` - `GET /auth/bots/{bot_id}` - `POST /auth/bots/{bot_id}/tokens` - `GET /auth/bots/{bot_id}/tokens` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}` - `DELETE /auth/bots/{bot_id}` - `POST /auth/me/bots` - `GET /auth/me/bots` - `POST /auth/me/bots/{bot_id}/tokens` - `GET /auth/me/bots/{bot_id}/tokens` - `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}` ### 포트폴리오 - `GET /portfolios` - `GET /portfolios/{portfolio_id}` - `POST /portfolios` - `PATCH /portfolios/{portfolio_id}` - `POST /portfolios/{portfolio_id}/buy` - `POST /portfolios/{portfolio_id}/sell` - `POST /portfolios/{portfolio_id}/invest` - `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` - `GET /portfolios/{portfolio_id}/comments` - `PATCH /portfolios/{portfolio_id}/comments/{comment_id}` - `DELETE /portfolios/{portfolio_id}/comments/{comment_id}` ### 전략셋 - `GET /strategy-sets` - `POST /strategy-sets` - `GET /strategy-sets/{strategy_set_id}` - `PATCH /strategy-sets/{strategy_set_id}` - `DELETE /strategy-sets/{strategy_set_id}` ### 사용자 - `GET /users` - `GET /users/{user_id}` - `POST /users` - `PATCH /users/{user_id}` - `DELETE /users/{user_id}` ## 오류 처리 기준 | 시나리오 | HTTP | | --- | --- | | 인증 헤더 누락/형식 오류 | `401` | | 유효하지 않은 토큰 | `401` | | 권한 부족 | `403` | | 유효성 검증 실패 | `422` | | 리소스 없음 | `404` | | 내부 예외 | `500` | ## 상세 엔드포인트 명세 ### 헬스 #### `GET /health` - 인증 없음 - 응답 `200`: ```json {"status": "ok"} ``` ### 포트폴리오 #### `GET /portfolios` - Auth: `read` - Ownership: `admin`은 전체 조회, 그 외는 본인 소유(`owner_user_id == auth_context.user_id`)만 조회 - 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&includeTags=grade:A&excludeTags=blacklist:trade&matchMode=OR" \ -H "Authorization: Bearer " ``` ```json [ { "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", "tags": ["grade:A"], "cash_balance": 1000000.0, "asset_quantity": 0.1, "price": 100000000.0, "profit_buffer": 0.0, "active_strategy_id": "stv-001", "avabile_strategies": ["stv-001"], "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z", "last_trade_at": "2026-04-03T00:00:00Z", "total_value": 11000000.0 } ] ``` #### `GET /portfolios/{portfolio_id}` - Auth: `read` - Ownership: `admin`이 아니고 소유주 불일치 시 `403` - Query: - `recent_trades: int = 5` - 응답 `200`: `PortfolioDetailResponse` ```bash curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \ -H "Authorization: Bearer " ``` ```json { "portfolio": { "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", "tags": ["grade:A"], "owner_user_id": "user-1", "cash_balance": 1000000.0, "asset_quantity": 0.1, "price": 100000000.0, "profit_buffer": 0.0, "active_strategy_id": "stv-001", "avabile_strategies": ["stv-001"], "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z", "last_trade_at": "2026-04-03T00:00:00Z" }, "recent_entries": [ { "portfolio_id": "krw-btc", "recorded_at": "2026-04-03T00:00:00Z", "action": "buy", "price": 100000000.0, "quantity": 0.1, "fee_amount": 0.0, "tax_amount": 0.0, "cash_balance_after": 1000000.0, "asset_quantity_after": 0.1, "total_value_after": 11000000.0, "profit_buffer_after": 0.0, "strategy_set_id": "stv-001", "note": "" } ], "strategy_set": { "id": "stv-001", "name": "target_value", "config": "{}", "nick": "target_value:99914b932bd3", "created_at": "2026-04-03T00:00:00Z" }, "live_quantity": null } ``` #### `POST /portfolios` - Auth: `trade-write` - Ownership: 요청 body로 owner를 받지 않고 인증 컨텍스트 사용자로 `owner_user_id`를 강제 설정 - Body: `CreatePortfolioRequest` - 응답 `201`: `PortfolioDetailResponse` ```bash curl -X POST "http://localhost:8000/portfolios" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", "strategy_name": "target_value", "strategy_params": {}, "active_strategy_id": null, "avabile_strategies": null, "cash_balance": 1000000, "asset_quantity": 0, "asset_price": 0, "profit_buffer": 0 }' ``` #### `PATCH /portfolios/{portfolio_id}` - Auth: `trade-write` - Body: `UpdatePortfolioRequest` - 응답 `200`: `PortfolioDetailResponse` ```bash curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "broker": "upbit", "name": "Bitcoin", "symbol": "KRW-BTC" }' ``` #### 거래/자금 이동 엔드포인트 - `POST /portfolios/{portfolio_id}/buy`: `BuyRequest` -> `PortfolioDetailResponse` - `POST /portfolios/{portfolio_id}/sell`: `SellRequest` -> `PortfolioDetailResponse` - `POST /portfolios/{portfolio_id}/invest`: `InvestRequest` -> `PortfolioDetailResponse` - `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` 대표 요청: ```bash curl -X POST "http://localhost:8000/portfolios/krw-btc/buy" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' curl -X POST "http://localhost:8000/portfolios/krw-btc/sell" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' curl -X POST "http://localhost:8000/portfolios/krw-btc/invest" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"movement_type": "deposit", "amount": 50000, "note": ""}' curl -X POST "http://localhost:8000/portfolios/krw-btc/transfer" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"direction": "to-buffer", "amount": 10000, "note": ""}' curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"infer_symbol": "KRW-BTC"}' curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \ -H "Authorization: Bearer " curl -X POST "http://localhost:8000/portfolios/krw-btc/tags" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"tag": " grade:A "}' curl -X GET "http://localhost:8000/portfolios/krw-btc/tags" \ -H "Authorization: Bearer " curl -X DELETE "http://localhost:8000/portfolios/krw-btc/tags" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"tag": "grade:A"}' ``` #### 포트폴리오 댓글 (`REQ-001`) - `POST /portfolios/{portfolio_id}/comments` - Auth: `trade-write` - Body: `CommentCreateRequest` - Response `201`: `CommentResponse` - `GET /portfolios/{portfolio_id}/comments` - Auth: `read` - Response `200`: `CommentResponse[]` - `PATCH /portfolios/{portfolio_id}/comments/{comment_id}` - Auth: `trade-write` - Body: `CommentUpdateRequest` - Response `200`: `CommentResponse` - `DELETE /portfolios/{portfolio_id}/comments/{comment_id}` - Auth: `trade-write` - Response `204` 예시: ```bash curl -X POST "http://localhost:8000/portfolios/krw-btc/comments" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"content": "Bought more BTC today.\nLooking good."}' curl -X GET "http://localhost:8000/portfolios/krw-btc/comments" \ -H "Authorization: Bearer " curl -X PATCH "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"content": "Updated note: BTC rally continues."}' curl -X DELETE "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ -H "Authorization: Bearer " ``` ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "portfolio_id": "krw-btc", "content": "Bought more BTC today.\nLooking good.", "created_at": "2026-04-10T00:00:00Z", "updated_at": "2026-04-10T00:00:00Z" } ``` ### 전략셋 #### `GET /strategy-sets` - Auth: `read` - Response `200`: `StrategySetResponse[]` #### `POST /strategy-sets` - Auth: `trade-write` - Body: `CreateStrategySetRequest` - Response `201`: `StrategySetResponse` - 기존 `(name, config)` 쌍이 있으면 기존 strategy set 반환 #### `PATCH /strategy-sets/{strategy_set_id}` - Auth: `trade-write` - Body: `UpdateStrategySetRequest` - Response `200`: `StrategySetResponse` #### `DELETE /strategy-sets/{strategy_set_id}` - Auth: `trade-write` - Response `204` - in use 상태면 `409` 대표 요청: ```bash curl -X POST "http://localhost:8000/strategy-sets" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "target_value", "config": "{}"}' curl -X PATCH "http://localhost:8000/strategy-sets/stv-001" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"config": "{\"rebalance\": true}"}' curl -X DELETE "http://localhost:8000/strategy-sets/stv-001" \ -H "Authorization: Bearer " ``` ### 인증 #### `POST /auth/login` - Auth 없음 - Body: `LoginRequest` - Response `200`: `TokenResponse` - 잘못된 username/password면 `401` ```bash curl -X POST "http://localhost:8000/auth/login" \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "bootstrap123" }' ``` ```json { "access_token": "plain-token-value", "token_type": "bearer", "expires_at": "2026-04-03T12:00:00Z", "permission": "admin", "user": { "id": "user-1", "username": "admin", "email": "admin@example.com", "permission": "admin", "is_active": true, "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z", "last_login_at": "2026-04-03T00:00:00Z" } } ``` #### `GET /auth/tokens` - Auth: DB-issued `read` 이상 토큰 - Response `200`: `AuthTokenResponse[]` #### `POST /auth/tokens` - Auth: DB-issued `read` 이상 토큰 - Body: `CreateClientTokenRequest` - Response `201`: `IssuedClientTokenResponse` - 요청 permission이 현재 permission을 넘으면 `403` #### `DELETE /auth/tokens/{token_id}` - Auth: DB-issued `read` 이상 토큰 - Response `204` - 미존재 시 `404` 대표 요청: ```bash curl -X POST "http://localhost:8000/auth/tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "cli", "permission": "trade-write", "expires_in_days": 30}' ``` #### 봇 주체와 봇 토큰 - `POST /auth/bots`: admin only, `CreateBotPrincipalRequest` -> `BotPrincipalResponse` - `GET /auth/bots`: admin only, `is_active` 필터 지원 - `GET /auth/bots/{bot_id}`: admin only - `DELETE /auth/bots/{bot_id}`: admin only, bot 비활성화 및 연결 토큰 revoke, 응답 `200` - `POST /auth/bots/{bot_id}/tokens`: admin only, `CreateBotTokenRequest` -> `IssuedBotTokenResponse` - `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204` 소유권/권한 제약: - bot principal은 `owner_user_id`를 필수로 가진다. - bot permission은 `read` 또는 `trade-write`만 허용한다. - bot 토큰 인증 컨텍스트의 실효 `user_id`는 bot owner(`owner_user_id`)로 해석된다. #### 사용자 전용(Self-service) 봇 관리 - `POST /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, `CreateSelfBotPrincipalRequest` -> `BotPrincipalResponse` - `GET /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, 본인 소유 bot 목록 - `POST /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `CreateBotTokenRequest` -> `IssuedBotTokenResponse` - `GET /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `AuthTokenResponse[]` - `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`: DB-issued `read` 이상 사용자 토큰, `204` 제약: - `/auth/me/bots*` 경로는 요청자 본인 소유 bot(`owner_user_id == auth_context.user_id`)에만 허용된다. - 타 사용자 소유 bot 접근 시 `403`. 대표 요청: ```bash curl -X POST "http://localhost:8000/auth/bots" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "rebalance-bot", "owner_user_id": "user-1", "permission": "trade-write", "description": "nightly jobs"}' curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}' curl -X POST "http://localhost:8000/auth/me/bots" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "my-bot", "permission": "read", "description": "personal automation"}' curl -X POST "http://localhost:8000/auth/me/bots/bot-1/tokens" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "personal-key", "permission": "read", "expires_in_days": 30}' ``` ### 사용자 모든 `/users` 엔드포인트는 admin only이다. - `GET /users`: `UserResponse[]` - `GET /users/{user_id}`: `UserResponse`, 미존재 시 `404` - `POST /users`: `CreateUserRequest` -> `UserResponse`, duplicate username/email이면 `409` - `PATCH /users/{user_id}`: `UpdateUserRequest` -> `UserResponse`, duplicate면 `409` - `DELETE /users/{user_id}`: `204` bootstrap 규칙: - DB persistence가 활성이고 `PORTFOLIOHUB_BOOTSTRAP_USERNAME`, `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`가 설정되면, `users` 테이블이 비어 있을 때만 startup에서 초기 admin user를 생성한다. 대표 요청: ```bash curl -X GET "http://localhost:8000/users" \ -H "Authorization: Bearer " curl -X POST "http://localhost:8000/users" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "username": "alice", "email": "alice@example.com", "password": "password123", "permission": "read", "is_active": true }' ``` ## 요청/응답 모델 요약 ### Request Models `CreatePortfolioRequest` - `portfolio_id: str` - `broker: str = "kis"` - `status: str = "enabled"` - `name: str | null` - `symbol: str` - `strategy_name: str = "target_value"` - `strategy_params: object = {}` - `active_strategy_id: str | null` - `avabile_strategies: string[] | null` - `cash_balance: float >= 0` - `asset_quantity: float >= 0` - `asset_price: float >= 0` - `profit_buffer: float >= 0` `UpdatePortfolioRequest` - `broker: str | null` - `status: str | null` - `name: str | null` - `symbol: str | null` - `strategy_name: str | null` - `strategy_params: object | null` - `active_strategy_id: str | null` - `avabile_strategies: string[] | null` - `asset_price: float >= 0 | null` `BuyRequest` / `SellRequest` - `quantity: float > 0` `InvestRequest` - `movement_type: "deposit" | "profit" | "withdraw"` - `amount: float > 0` - `note: str = ""` `TransferRequest` - `direction: "to-buffer" | "from-buffer"` - `amount: float > 0` - `note: str = ""` `ResetPortfolioRequest` - `infer_symbol: str | null` `CommentCreateRequest` / `CommentUpdateRequest` - `content: str` `LoginRequest` - `username: str` - `password: str` `CreateClientTokenRequest` - `name: str` - `permission: "read" | "trade-write" | "admin" | null` - `expires_in_days: int | null` `CreateBotPrincipalRequest` - `name: str` - `owner_user_id: str` - `permission: "read" | "trade-write"` - `description: str | null` `CreateSelfBotPrincipalRequest` - `name: str` - `permission: "read" | "trade-write"` - `description: str | null` `CreateBotTokenRequest` - `name: str` - `permission: "read" | "trade-write" | null` - `expires_in_days: int | null` `CreateUserRequest` - `username: str` - `email: str | null` - `password: str` - `permission: "read" | "trade-write" | "admin"` - `is_active: bool` `UpdateUserRequest` - `username: str | null` - `email: str | null` - `password: str | null` - `permission: "read" | "trade-write" | "admin" | null` - `is_active: bool | null` `CreateStrategySetRequest` - `name: str` - `config: str` `UpdateStrategySetRequest` - `name: str | null` - `config: str | null` ### Response Models `PortfolioListItem` - portfolio summary fields + computed `total_value` `PortfolioSummary` - `portfolio_id: str` - `broker: str` - `status: str` - `name: str` - `symbol: str` - `cash_balance: float` - `asset_quantity: float` - `price: float` - `profit_buffer: float` - `active_strategy_id: str | null` - `avabile_strategies: string[] | null` - `created_at: datetime | null` - `updated_at: datetime | null` - `last_trade_at: datetime | null` `PortfolioDetailResponse` - `portfolio: PortfolioSummary` - `recent_entries: LedgerEntryResponse[]` - `strategy_set: StrategySetResponse | null` - `live_quantity: float | null` `StrategySetResponse` - `id: str` - `name: str` - `config: str` - `nick: str` - `created_at: datetime | null` `CommentResponse` - `id: str` - `portfolio_id: str` - `content: str` - `created_at: datetime | null` - `updated_at: datetime | null` `BotPrincipalResponse` - `id: str` - `name: str` - `owner_user_id: str` - `permission: "read" | "trade-write"` - `description: str | null` - `is_active: bool` - `created_at: datetime | null` - `updated_at: datetime | null` - `deactivated_at: datetime | null` `ErrorResponse` - `detail: str` ## 공통 오류 예시 ### Missing token ```json {"detail": "Not authenticated"} ``` ### Invalid token ```json {"detail": "Invalid token"} ``` ### Insufficient permission ```json {"detail": "Insufficient permission"} ``` ## 문서 동기화 규칙 - `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다. - `REQ-002` 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다. ## Open Implementation Points - 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다. - idempotency가 필요한 쓰기 API(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.