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

21 KiB

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 (인증 없음)
  • 정상 응답 예시:
{"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:
{"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가 아님

예시:

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>"
[
	{
		"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
curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
	-H "Authorization: Bearer <READ_TOKEN>"
{
	"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
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
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
  • 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

대표 요청:

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>"

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)

  • 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

예시:

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>"
{
	"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

대표 요청:

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
curl -X POST "http://localhost:8000/auth/login" \
	-H "Content-Type: application/json" \
	-d '{
		"username": "admin",
		"password": "bootstrap123"
	}'
{
	"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

대표 요청:

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

소유권/권한 제약:

  • 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.

대표 요청:

curl -X POST "http://localhost:8000/auth/bots" \
	-H "Authorization: Bearer <ADMIN_TOKEN>" \
	-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 <ADMIN_TOKEN>" \
	-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 <USER_TOKEN>" \
	-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 <USER_TOKEN>" \
	-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를 생성한다.

대표 요청:

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
  • 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

{"detail": "Not authenticated"}

Invalid token

{"detail": "Invalid token"}

Insufficient permission

{"detail": "Insufficient permission"}

문서 동기화 규칙

  • app/api/ 또는 app/schemas/ 변경 시 본 문서를 같은 변경 단위에서 갱신한다.
  • REQ-002 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다.

Open Implementation Points

  • 에러 응답 body를 공통 포맷(error_code, message, details)으로 강제할지 결정한다.
  • idempotency가 필요한 쓰기 API(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.