Files
portfolio-hub/docs/technical/api-contracts.md
T

669 lines
17 KiB
Markdown

# API Contracts
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004`
## 공통 원칙
- 응답은 JSON 기반으로 반환한다.
- 인증이 필요한 엔드포인트는 `Authorization: Bearer <token>`을 사용한다.
- 권한 계층은 `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 <READ_TOKEN>"
```
```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 <READ_TOKEN>"
```
```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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>" \
-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 <ADMIN_TOKEN>"
```
#### 포트폴리오 댓글 (`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 <TRADE_WRITE_TOKEN>" \
-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 <READ_TOKEN>"
curl -X PATCH "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-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 <TRADE_WRITE_TOKEN>"
```
```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 <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "target_value", "config": "{}"}'
curl -X PATCH "http://localhost:8000/strategy-sets/stv-001" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"config": "{\"rebalance\": true}"}'
curl -X DELETE "http://localhost:8000/strategy-sets/stv-001" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>"
```
### 인증
#### `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 <SESSION_TOKEN>" \
-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 <ADMIN_TOKEN>" \
-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 <ADMIN_TOKEN>" \
-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 <ADMIN_TOKEN>"
curl -X POST "http://localhost:8000/users" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-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(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.