# 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` - `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}` ### 포트폴리오 - `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}` ### 포트폴리오 댓글 (`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` - 응답 `200`: `PortfolioListItem[]` 예시: ```bash curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \ -H "Authorization: Bearer " ``` ```json [ { "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", "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", "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` 대표 요청: ```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 " ``` #### 포트폴리오 댓글 (`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` 대표 요청: ```bash curl -X POST "http://localhost:8000/auth/bots" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "rebalance-bot", "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}' ``` ### 사용자 모든 `/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` - `permission: "read" | "trade-write" | "admin"` - `description: str | null` `CreateBotTokenRequest` - `name: str` - `permission: "read" | "trade-write" | "admin" | 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` - `permission: "read" | "trade-write" | "admin"` - `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` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다. ## Open Implementation Points - 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다. - idempotency가 필요한 쓰기 API(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.