refactor docs into technical specs

This commit is contained in:
2026-04-13 23:20:25 +09:00
parent f3c870553d
commit 256b3709a0
19 changed files with 1713 additions and 2087 deletions
@@ -32,7 +32,7 @@
7. Given 소비 서비스가 태그 필터를 포함해 목록 조회를 요청한 상태, When 조회를 수행하면, Then 태그 필터 적용 규칙은 REQ-002에서 정의한 include/exclude 및 매칭 규칙을 따라야 한다.
## Impacted Area
- Docs: `docs/db-schema-spec.md`, `docs/portfoliohub-spec.md`, `docs/api-examples.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
- Docs: `docs/technical/architecture-overview.md`, `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
- Modules: `app/domain/models.py`, `app/schemas/portfolio.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/services/portfolio_service.py`, `migrations/versions/*`
- Tests: `tests/test_app.py`, `tests/test_domain_services.py`
@@ -46,6 +46,38 @@
## Change Log
### CHG-20260413-08
- Date: 2026-04-13
- Author: GitHub Copilot
#### Change Summary
- 삭제 예정인 상위 docs 경로를 technical 문서 경로로 정리했다.
#### Intent
- 데이터 계약 requirement가 삭제 후에도 유효한 구현 추적 문서를 가리키도록 유지하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- Impacted Area의 docs 경로를 `docs/technical/` 기준으로 갱신
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Data/REQ-003_portfolio-required-fields.md`
- `docs/technical/architecture-overview.md`
- `docs/technical/api-contracts.md`
- `docs/technical/persistence-and-migrations.md`
- Modules:
- 없음
- Tests:
- 없음
#### Validation Plan
- 삭제 대상 문서 제거 후에도 REQ-003의 docs 추적 링크가 모두 유효한지 확인한다.
### CHG-20260413-07
- Date: 2026-04-13
- Author: GitHub Copilot
@@ -261,9 +293,9 @@
#### Impacted Area Delta
- Docs:
- db-schema-spec.md
- portfoliohub-spec.md
- api-examples.md
- docs/technical/architecture-overview.md
- docs/technical/api-contracts.md
- docs/technical/persistence-and-migrations.md
- index.md
- traceability-matrix.md
- Modules:
@@ -30,7 +30,7 @@
7. Given 존재하지 않는 `portfolio_id` 또는 `comment_id`가 요청에 포함될 때, When 사용자가 API 또는 CLI로 조회·수정·삭제를 시도하면, Then 시스템은 `404` 응답 또는 이에 대응하는 CLI 오류를 반환하고 새로운 메모를 생성하거나 기존 데이터를 변경하지 않아야 한다.
## Impacted Area
- Docs: `docs/portfoliohub-spec.md`, `docs/api-examples.md`, `docs/db-schema-spec.md`, `docs/SRS/index.md`
- Docs: `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`
- Modules: `app/api/portfolios.py`, `app/cli/commands/portfolio.py`, `app/schemas/portfolio.py`, `app/services/portfolio_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `migrations/versions/*`
- Tests: `tests/test_app.py`, `tests/test_cli.py`, `tests/test_domain_services.py`
@@ -41,6 +41,37 @@
## Change Log
### CHG-20260413-04
- Date: 2026-04-13
- Author: GitHub Copilot
#### Change Summary
- 삭제 예정인 상위 docs 경로를 technical 문서 경로로 정리했다.
#### Intent
- 구현 추적 문서가 삭제 후에도 유효한 참조를 유지하도록 하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- Impacted Area의 docs 경로를 `docs/technical/` 기준으로 갱신
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Interface/REQ-001_portfolio-comment-history.md`
- `docs/technical/api-contracts.md`
- `docs/technical/persistence-and-migrations.md`
- Modules:
- 없음
- Tests:
- 없음
#### Validation Plan
- 삭제 대상 문서 제거 후에도 REQ-001의 구현 추적 문서 링크가 모두 유효한지 확인한다.
### CHG-20260410-03
- Date: 2026-04-10
- Author: GitHub Copilot
@@ -126,9 +157,8 @@
#### Impacted Area Delta
- Docs:
- portfoliohub-spec.md
- api-examples.md
- db-schema-spec.md
- docs/technical/api-contracts.md
- docs/technical/persistence-and-migrations.md
- `docs/SRS/index.md`
- Modules:
- portfolios.py
@@ -35,7 +35,7 @@
10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 포함 조건을 만족하고 제외 조건을 만족하지 않는 항목만 반환해야 한다.
## Impacted Area
- Docs: `docs/portfoliohub-spec.md`, `docs/api-examples.md`, `docs/db-schema-spec.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
- Docs: `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`
- Modules: `app/api/portfolios.py`, `app/cli/commands/portfolio.py`, `app/schemas/portfolio.py`, `app/services/portfolio_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/domain/models.py`, `migrations/versions/*`
- Tests: `tests/test_app.py`, `tests/test_cli.py`, `tests/test_domain_services.py`
@@ -47,6 +47,37 @@
## Change Log
### CHG-20260413-05
- Date: 2026-04-13
- Author: GitHub Copilot
#### Change Summary
- 삭제 예정인 상위 docs 경로를 technical 문서 경로로 정리했다.
#### Intent
- 태그 기능 requirement가 삭제 후에도 유효한 구현 추적 문서를 가리키도록 유지하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 없음
- Updated:
- Impacted Area의 docs 경로를 `docs/technical/` 기준으로 갱신
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md`
- `docs/technical/api-contracts.md`
- `docs/technical/persistence-and-migrations.md`
- Modules:
- 없음
- Tests:
- 없음
#### Validation Plan
- 삭제 대상 문서 제거 후에도 REQ-002의 docs 추적 링크가 모두 유효한지 확인한다.
### CHG-20260413-04
- Date: 2026-04-13
- Author: GitHub Copilot
@@ -165,9 +196,8 @@
#### Impacted Area Delta
- Docs:
- portfoliohub-spec.md
- api-examples.md
- db-schema-spec.md
- docs/technical/api-contracts.md
- docs/technical/persistence-and-migrations.md
- index.md
- traceability-matrix.md
- Modules:
-834
View File
@@ -1,834 +0,0 @@
# PortfolioHub API Examples
## 1. Purpose
This document contains example requests and responses for PortfolioHub HTTP APIs.
- Sync rule: when files under `app/api/` or `app/schemas/` change, this document must be updated in the same commit/PR.
- Companion spec: `docs/portfoliohub-spec.md`
## 2. Conventions
- Base URL example: `http://localhost:8000`
- Auth header format: `Authorization: Bearer <TOKEN>`
- JSON examples are representative and may omit some optional fields.
## 3. Health
### 3.1 GET /health
Request:
```bash
curl -X GET "http://localhost:8000/health"
```
Response 200:
```json
{
"status": "ok"
}
```
## 4. Portfolios
### 4.1 GET /portfolios
Request:
```bash
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```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
}
]
```
### 4.2 GET /portfolios/{portfolio_id}
Request:
```bash
curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```json
{
"portfolio": {
"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"
},
"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
}
```
### 4.3 POST /portfolios
Request:
```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
}'
```
Response 201:
- Response body shape is `PortfolioDetailResponse` (same as 4.2).
### 4.4 PATCH /portfolios/{portfolio_id}
Request:
```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"
}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.5 POST /portfolios/{portfolio_id}/buy
Request:
```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": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.6 POST /portfolios/{portfolio_id}/sell
Request:
```bash
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": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.7 POST /portfolios/{portfolio_id}/invest
Request:
```bash
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": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.8 POST /portfolios/{portfolio_id}/transfer
Request:
```bash
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": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.9 POST /portfolios/{portfolio_id}/reset
Request:
```bash
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"}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.10 DELETE /portfolios/{portfolio_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 204:
- No content.
### 4.11 POST /portfolios/{portfolio_id}/comments
Create a new comment for a portfolio.
Required permission: `trade-write`
Request:
```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."}'
```
Response 201:
```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"
}
```
### 4.12 GET /portfolios/{portfolio_id}/comments
List all comments for a portfolio, sorted by most recent first.
Required permission: `read`
Request:
```bash
curl -X GET "http://localhost:8000/portfolios/krw-btc/comments" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```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"
}
]
```
### 4.13 PATCH /portfolios/{portfolio_id}/comments/{comment_id}
Update an existing comment's content.
Required permission: `trade-write`
Request:
```bash
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."}'
```
Response 200:
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"portfolio_id": "krw-btc",
"content": "Updated note: BTC rally continues.",
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T01:00:00Z"
}
```
### 4.14 DELETE /portfolios/{portfolio_id}/comments/{comment_id}
Delete a comment.
Required permission: `trade-write`
Request:
```bash
curl -X DELETE "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>"
```
Response 204:
- No content.
## 5. Strategy Sets
### 5.1 GET /strategy-sets
## 6. Auth
### 6.1 POST /auth/login
Request:
```bash
curl -X POST "http://localhost:8000/auth/login" \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "bootstrap123"
}'
```
Response 200:
```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"
}
}
```
### 6.2 POST /auth/tokens
Request:
```bash
curl -X POST "http://localhost:8000/auth/tokens" \
-H "Authorization: Bearer <SESSION_OR_DB_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "mobile-app",
"permission": "read",
"expires_in_days": 30
}'
```
Response 201:
```json
{
"token": "plain-client-token",
"token_type": "bearer",
"auth_token": {
"id": "token-1",
"user_id": "user-1",
"name": "mobile-app",
"token_prefix": "abcd1234",
"permission": "read",
"token_type": "client",
"bot_principal_id": null,
"expires_at": "2026-05-03T00:00:00Z",
"last_used_at": null,
"revoked_at": null,
"created_at": "2026-04-03T00:00:00Z"
}
}
```
### 6.3 GET /auth/tokens
Request:
```bash
curl -X GET "http://localhost:8000/auth/tokens" \
-H "Authorization: Bearer <SESSION_OR_DB_TOKEN>"
```
### 6.4 DELETE /auth/tokens/{token_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/tokens/token-1" \
-H "Authorization: Bearer <SESSION_OR_DB_TOKEN>"
```
Response 204:
- No content.
### 6.5 POST /auth/bots
Request:
```bash
curl -X POST "http://localhost:8000/auth/bots" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "market-bot",
"permission": "trade-write",
"description": "External trading bot"
}'
```
Response 201:
```json
{
"id": "bot-1",
"name": "market-bot",
"permission": "trade-write",
"description": "External trading bot",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z",
"deactivated_at": null
}
```
### 6.6 GET /auth/bots
Request:
```bash
curl -X GET "http://localhost:8000/auth/bots?is_active=true" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
### 6.7 POST /auth/bots/{bot_id}/tokens
Request:
```bash
curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "prod-key",
"permission": "read",
"expires_in_days": 30
}'
```
Response 201:
```json
{
"token": "plain-bot-token",
"token_type": "bearer",
"auth_token": {
"id": "token-2",
"user_id": "user-1",
"name": "prod-key",
"token_prefix": "efgh5678",
"permission": "read",
"token_type": "bot",
"bot_principal_id": "bot-1",
"expires_at": "2026-05-03T00:00:00Z",
"last_used_at": null,
"revoked_at": null,
"created_at": "2026-04-03T00:00:00Z"
}
}
```
### 6.8 GET /auth/bots/{bot_id}/tokens
Request:
```bash
curl -X GET "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
### 6.9 DELETE /auth/bots/{bot_id}/tokens/{token_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/bots/bot-1/tokens/token-2" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 204:
- No content.
### 6.10 DELETE /auth/bots/{bot_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/bots/bot-1" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 200:
```json
{
"id": "bot-1",
"name": "market-bot",
"permission": "trade-write",
"description": "External trading bot",
"is_active": false,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T01:00:00Z",
"deactivated_at": "2026-04-03T01:00:00Z"
}
```
## 7. Users
All user management endpoints require an admin token.
### 7.1 GET /users
Request:
```bash
curl -X GET "http://localhost:8000/users" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 200:
```json
[
{
"id": "user-1",
"username": "alice",
"email": "alice@example.com",
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
}
]
```
### 7.2 GET /users/{user_id}
Request:
```bash
curl -X GET "http://localhost:8000/users/user-1" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 200:
```json
{
"id": "user-1",
"username": "alice",
"email": "alice@example.com",
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
}
```
### 7.3 POST /users
Request:
```bash
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
}'
```
Response 201:
```json
{
"id": "user-1",
"username": "alice",
"email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z",
"last_login_at": null
}
```
### 7.4 PATCH /users/{user_id}
Request:
```bash
curl -X PATCH "http://localhost:8000/users/user-1" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"username": "alice-updated"
}'
```
Response 200:
```json
{
"id": "user-1",
"username": "alice-updated",
"email": "alice@example.com",
"permission": "read",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:10:00Z",
"last_login_at": null
}
```
### 7.5 DELETE /users/{user_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/users/user-1" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 204:
- No content.
Request:
```bash
curl -X GET "http://localhost:8000/strategy-sets" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```json
[
{
"id": "set-1",
"name": "target_value",
"config": "{\"target\": 1000}",
"nick": "target_value:abc123def456",
"created_at": "2026-04-03T08:13:08.318Z"
},
{
"id": "set-2",
"name": "momentum",
"config": "{\"window\": 20}",
"nick": "momentum:xyz789uvw012",
"created_at": "2026-04-03T08:14:15.450Z"
}
]
```
### 5.2 POST /strategy-sets
Request:
```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": "{\"target\": 1000}"
}'
```
Response 201:
```json
{
"id": "set-1",
"name": "target_value",
"config": "{\"target\": 1000}",
"nick": "target_value:abc123def456",
"created_at": "2026-04-03T08:13:08.318Z"
}
```
### 5.3 PATCH /strategy-sets/{strategy_set_id}
Request:
```bash
curl -X PATCH "http://localhost:8000/strategy-sets/set-1" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"config": "{\"target\": 2000}"
}'
```
Response 200:
```json
{
"id": "set-1-updated",
"name": "target_value",
"config": "{\"target\": 2000}",
"nick": "target_value:def789ghi012",
"created_at": "2026-04-03T08:13:08.318Z"
}
```
Note: Since strategy sets are identified by (name, config) uniqueness, updating may result in a new strategy set ID.
### 5.4 DELETE /strategy-sets/{strategy_set_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/strategy-sets/set-1" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>"
```
Response 204:
- No content on success.
Response 409 Conflict (if strategy set is in use):
```json
{
"detail": "Strategy set is in use by 1 portfolio(s) and cannot be deleted"
}
```
## 6. Common Error Examples
### 6.1 Missing token
Response 401:
```json
{
"detail": "Missing bearer token"
}
```
### 6.2 Invalid token
Response 401:
```json
{
"detail": "Invalid token"
}
```
### 6.3 Insufficient permission
Response 403:
```json
{
"detail": "Insufficient permission"
}
```
-332
View File
@@ -1,332 +0,0 @@
# PortfolioHub DB Schema Specification
## 1. Purpose
This document defines the database schema in an AI-agent-friendly format.
- Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260410_0009`
- Baseline files:
- `migrations/versions/20260403_0001_initial_portfolio_schema.py`
- `migrations/versions/20260403_0002_strategy_set_columns.py`
- `migrations/versions/20260403_0003_portfolio_strategy_refs.py`
- `migrations/versions/20260403_0004_users_table.py`
- `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py`
- `migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py`
- `migrations/versions/20260403_0007_bot_principal_permission_only.py`
- `migrations/versions/20260403_0008_portfolio_name_column.py`
- `migrations/versions/20260410_0009_portfolio_comments_table.py`
## 2. Scope
Schema objects covered by baseline revision:
- `strategy_sets`
- `users`
- `auth_tokens`
- `bot_principals`
- `portfolios`
- `portfolio_ledger`
- `portfolio_comments`
- secondary indexes
- Timescale hypertable conversion rule for `portfolio_ledger`
## 3. Conventions
- Data type names follow SQLAlchemy/Alembic declaration.
- `NULL` means nullable.
- Default means DB-level `server_default` where defined.
- Relationship columns are documented even when no FK constraint exists.
## 4. Tables
### 4.1 users
Purpose:
- Stores admin-managed user records for operational ownership and future identity mapping.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| username | Text | NO | - | Unique username |
| email | Text | YES | - | Unique when present |
| permission | Text | NO | "read" | User permission level |
| is_active | Boolean | NO | TRUE | Whether login is allowed |
| password_hash | Text | YES | - | Scrypt password hash |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_login_at | DateTime(timezone=True) | YES | - | Last successful login timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_users_username` on (`username`)
- Unique: `uq_users_email` on (`email`)
### 4.2 auth_tokens
Purpose:
- Stores DB-issued session tokens and client tokens.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| user_id | Text | NO | - | Logical user reference |
| name | Text | NO | - | Human-readable token name |
| token_prefix | Text | NO | - | Display prefix for issued token |
| token_hash | Text | NO | - | Unique SHA-256 token hash |
| permission | Text | NO | - | Effective token permission |
| token_type | Text | NO | - | `session`, `client`, or `bot` |
| bot_principal_id | Text | YES | - | Logical reference to `bot_principals.id` |
| expires_at | DateTime(timezone=True) | YES | - | Expiration time |
| last_used_at | DateTime(timezone=True) | YES | - | Last successful use |
| revoked_at | DateTime(timezone=True) | YES | - | Revocation time |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_auth_tokens_token_hash` on (`token_hash`)
### 4.3 bot_principals
Purpose:
- Stores external bot identities for admin-managed API key issuance and lifecycle control.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| name | Text | NO | - | Unique bot name |
| permission | Text | NO | - | Bot permission ceiling |
| description | Text | YES | - | Optional operator description |
| is_active | Boolean | NO | TRUE | When false, bot tokens are rejected |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| deactivated_at | DateTime(timezone=True) | YES | - | Deactivation time |
Constraints:
- Primary key: `id`
- Unique: `uq_bot_principals_name` on (`name`)
### 4.3 strategy_sets
Purpose:
- Stores normalized strategy configuration snapshots.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| name | Text | NO | - | Strategy name, part of unique pair |
| config | Text | NO | "" | Strategy config payload, part of unique pair |
| nick | Text | NO | - | Unique strategy set nickname |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_strategy_sets_name_config` on (`name`, `config`)
- Unique: `uq_strategy_sets_nick` on (`nick`)
### 4.4 portfolios
Purpose:
- Stores current portfolio state and strategy linkage.
Current baseline note:
- The columns below describe the currently migrated schema through revision `20260403_0008`.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| portfolio_id | Text | NO | - | Primary Key |
| broker | Text | NO | "kis" | Broker code |
| status | Text | NO | "enabled" | Portfolio status |
| name | Text | NO | - | Asset name |
| symbol | Text | NO | - | Trading symbol |
| cash_balance | Float | NO | - | Current cash balance |
| asset_quantity | Float | NO | - | Current asset quantity |
| asset_price | Float | NO | 0 | Current price snapshot |
| profit_buffer | Float | NO | 0 | Profit buffer amount |
| active_strategy_id | Text | YES | - | Logical reference to `strategy_sets.id` |
| avabile_strategies | Text[] | YES | - | Nullable array of `strategy_sets.id` values |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_trade_at | DateTime(timezone=True) | YES | - | Last trade time |
Constraints:
- Primary key: `portfolio_id`
- Foreign key constraints: none in baseline
Constraint notes:
- `active_strategy_id` is a nullable logical reference to `strategy_sets.id`
- `avabile_strategies` stores zero or more selectable strategy set IDs when present
### 4.5 portfolio_ledger
Purpose:
- Stores append-like ledger snapshots per portfolio action.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` (no FK) |
| recorded_at | DateTime(timezone=True) | NO | - | Event timestamp |
| action | Text | NO | - | Action type |
| price | Float | NO | - | Trade/valuation unit price |
| quantity | Float | NO | - | Quantity delta |
| fee_amount | Float | NO | 0 | Fees |
| tax_amount | Float | NO | 0 | Taxes |
| cash_balance_after | Float | NO | - | Post-action cash |
| asset_quantity_after | Float | NO | - | Post-action asset quantity |
| total_value_after | Float | NO | - | Post-action total value |
| profit_buffer_after | Float | NO | 0 | Post-action profit buffer |
| strategy_set_id | Text | NO | "legacy" | Logical strategy set reference |
| note | Text | NO | "" | Operator/system note |
Constraints:
- Primary key: none in baseline
- Foreign key constraints: none in baseline
### 4.6 portfolio_comments
Purpose:
- Stores user-authored comment history per portfolio for memo/annotation tracking.
- Supports multiline text content.
- Comments are isolated per portfolio and listed in reverse chronological order.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key (UUID) |
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` (no FK) |
| content | Text | NO | - | Comment text; supports multiline |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
Constraints:
- Primary key: `id`
- Foreign key constraints: none
## 5. Indexes
### 5.1 ix_users_created_at
- Table: `users`
- Columns/expressions: (`created_at`)
- Unique: no
- Intent: speed operational listing by recency.
### 5.2 idx_auth_tokens_user_created
- Table: `auth_tokens`
- Columns/expressions: (`user_id`, `created_at`)
- Unique: no
- Intent: fetch current user's issued tokens efficiently.
### 5.3 idx_auth_tokens_hash_active
- Table: `auth_tokens`
- Columns/expressions: (`token_hash`)
- Unique: no
- Intent: resolve bearer tokens efficiently.
### 5.4 idx_auth_tokens_bot_created
- Table: `auth_tokens`
- Columns/expressions: (`bot_principal_id`, `created_at`)
- Unique: no
- Intent: fetch bot-issued token lists efficiently.
### 5.5 idx_bot_principals_active_created
- Table: `bot_principals`
- Columns/expressions: (`is_active`, `created_at`)
- Unique: no
- Intent: speed operational filtering of active/inactive bots.
### 5.6 idx_portfolio_ledger_portfolio_time
- Table: `portfolio_ledger`
- Columns/expressions: (`portfolio_id`, `recorded_at DESC`)
- Unique: no
- Intent: fetch recent ledger entries per portfolio efficiently.
### 5.7 idx_portfolios_broker_status_updated
- Table: `portfolios`
- Columns/expressions: (`broker`, `status`, `updated_at DESC`)
- Unique: no
- Intent: speed list/filter queries by broker/status and recency.
### 5.8 idx_portfolio_comments_portfolio_created
- Table: `portfolio_comments`
- Columns/expressions: (`portfolio_id`, `created_at DESC`)
- Unique: no
- Intent: fetch comments per portfolio efficiently in reverse chronological order.
Conditional behavior in migration upgrade:
- Checks whether extension `timescaledb` exists.
- If present, executes:
`create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)`
Implication:
- `portfolio_ledger` acts as a hypertable in environments with TimescaleDB extension enabled.
## 7. Integrity Notes
Current baseline intentionally has no DB-level FK constraints.
- Logical relations are managed by application/repository logic.
- When adding FK constraints in future migrations, update this document and include migration IDs.
- Cascade behavior implemented in application layer:
- Deleting a portfolio also deletes all associated `portfolio_comments` rows (hard delete, no FK constraint).
## 8. Change Management Rule
When schema changes:
1. Add/modify Alembic revision under `migrations/versions`.
2. Update this document in the same commit.
3. Keep revision mapping table below in sync.
## 9. Revision Mapping
| Revision ID | File | Summary |
|---|---|---|
| 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable |
| 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick`, handle legacy unnamed unique constraint |
| 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` |
| 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index |
| 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add password/permission/login fields to `users` and create `auth_tokens` table |
| 20260403_0006 | migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py | Add `bot_principals` table and `auth_tokens.bot_principal_id` for bot API key management |
| 20260403_0007 | migrations/versions/20260403_0007_bot_principal_permission_only.py | Remove `bot_principals.owner_user_id` and enforce bot-local `permission` model |
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` (asset display name), backfilled from `symbol` |
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo/comment history |
-194
View File
@@ -1,194 +0,0 @@
# Production Deployment: HTTPS with Caddy
This guide describes how to deploy PortfolioHub behind [Caddy](https://caddyserver.com/), a reverse proxy that automatically provisions and renews TLS certificates from Let's Encrypt or ZeroSSL.
## Prerequisites
- A Linux/Ubuntu server with a public IP address
- A domain name (e.g., `api.example.com`) with a DNS `A` record pointing to that IP
- Ports **80** and **443** open on your firewall (see [Firewall Setup](#firewall-setup))
---
## 1. Install Caddy (Ubuntu/Debian)
```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
Verify the installation:
```bash
caddy version
```
---
## 2. Configure the Caddyfile
Create or edit `/etc/caddy/Caddyfile`:
```
your-domain.com {
reverse_proxy localhost:8000
}
```
Replace `your-domain.com` with your actual domain. Caddy automatically:
- Obtains and renews TLS certificates from Let's Encrypt/ZeroSSL
- Redirects plain HTTP (port 80) to HTTPS (port 443)
- Forwards all requests to the PortfolioHub `uvicorn` process on port 8000
Reload Caddy after editing the Caddyfile:
```bash
sudo systemctl reload caddy
```
---
## 3. CORS Configuration
When PortfolioHub is accessed from a browser-based frontend over HTTPS, the `PORTFOLIOHUB_ALLOWED_ORIGINS` environment variable must list the exact HTTPS origin(s) of your frontend.
Set the variable in your environment or `.env` file:
```bash
PORTFOLIOHUB_ALLOWED_ORIGINS=https://app.example.com,https://www.example.com
```
Multiple origins are comma-separated. For the CORS policy details see section 12 of `docs/portfoliohub-spec.md`.
---
## 4. Start PortfolioHub for Production
Run `uvicorn` without `--reload` and bind only to `localhost` so that external traffic is always routed through Caddy:
```bash
uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
```
For a systemd service, create `/etc/systemd/system/portfoliohub.service`:
```ini
[Unit]
Description=PortfolioHub API
After=network.target
[Service]
User=www-data
WorkingDirectory=/opt/portfoliohub
EnvironmentFile=/opt/portfoliohub/.env
ExecStart=/opt/portfoliohub/.venv/bin/uvicorn app.main:app \
--host 127.0.0.1 --port 8000 --workers 2
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable portfoliohub
sudo systemctl start portfoliohub
```
---
## 5. Docker Compose Setup (Optional)
If you run PortfolioHub in Docker, you can add Caddy as a sidecar container.
`docker-compose.yml`:
```yaml
services:
app:
build: .
expose:
- "8000"
environment:
- PORTFOLIOHUB_ALLOWED_ORIGINS=https://your-domain.com
# add other env vars or reference an env_file
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
```
`Caddyfile` (placed next to `docker-compose.yml`):
```
your-domain.com {
reverse_proxy app:8000
}
```
Note: inside a Docker Compose network, Caddy reaches the app container via its service name (`app`) instead of `localhost`.
Start everything:
```bash
docker compose up -d
```
---
## 6. Firewall Setup
Open ports 80 (HTTP, needed for ACME certificate challenge) and 443 (HTTPS):
```bash
# UFW (Ubuntu)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
```
```bash
# firewalld (RHEL/CentOS/Fedora)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
```
---
## 7. Verify the Deployment
```bash
# Health check over HTTPS
curl https://your-domain.com/health
# Expected: {"status":"ok"}
```
Check Caddy's status and certificate renewal:
```bash
sudo systemctl status caddy
sudo journalctl -u caddy -f
```
-697
View File
@@ -1,697 +0,0 @@
# PortfolioHub Specification
## 1. Overview
PortfolioHub is a FastAPI-based portfolio lifecycle service with a Typer CLI.
- Runtime: Python 3.11+
- API framework: FastAPI
- CLI framework: Typer
- DB driver: Psycopg v3
- Migration: Alembic
Primary goals:
- Manage portfolio lifecycle (create/update/delete/reset)
- Record trade and balance-affecting actions (buy/sell/invest/transfer)
- Expose status and ledger snapshots through API and CLI
- Keep API and CLI behavior aligned through a shared service/repository layer
## 2. Scope and Non-Goals
In scope:
- Authenticated portfolio read and write operations
- Admin-managed user CRUD operations
- Role-based access control (read, trade-write, admin)
- Optional PostgreSQL/Timescale-backed repository via feature flag
- Operational commands for DB bootstrap and migration
Current non-goals / limitations:
- In-memory fallback repository is not implemented for production usage
- External broker execution is not handled here (service focuses on portfolio state)
- Advanced observability wiring for Redis/Loki is not yet implemented in runtime paths
## 3. Architecture
Layered components:
- API layer: HTTP routes and request validation
- CLI layer: command parsing and output rendering
- Service layer: orchestration and response shaping
- Domain layer: business logic rules
- Repository layer: persistence abstraction and concrete Postgres implementation
Key design principle:
- API and CLI call the same `PortfolioService`, so behavior is consistent regardless of entrypoint.
## 4. Entrypoints
HTTP service:
- App factory: `app.main:create_app`
- ASGI app: `app.main:app`
- OpenAPI docs: `/docs`
- ReDoc: `/redoc`
CLI:
- Console script: `portfoliohub`
- Entry function: `app.cli.main:main`
## 5. Configuration Specification
Configuration precedence (high to low):
1. Environment variables
2. `.env` file (loaded at startup if present)
3. Code defaults
Core settings:
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins
- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`: initial username created on startup when users table is empty
- `PORTFOLIOHUB_BOOTSTRAP_EMAIL`: optional email for the bootstrap user
- `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`: initial password for the bootstrap admin user
- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true`
- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL in hours
- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: default client token TTL in days
- `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS`: default bot token TTL in days
Database settings (active source):
- `TIMESCALEDB_HOST`
- `TIMESCALEDB_PORT`
- `TIMESCALEDB_NAME`
- `TIMESCALEDB_USER`
- `TIMESCALEDB_PASSWORD`
- `TIMESCALEDB_SSLMODE`
- `TIMESCALEDB_CONNECT_TIMEOUT_SEC`
- `TIMESCALEDB_APPLICATION_NAME`
Derived setting:
- `active_database_url` uses PostgreSQL psycopg URL format with encoded credentials and query options.
## 6. Authentication and Authorization
Auth scheme:
- `Authorization: Bearer <token>` required for protected API routes
Permission levels:
- `read`
- `trade-write`
- `admin`
Authorization hierarchy:
- `admin` can access all endpoints
- `trade-write` can access `trade-write` + `read`
- `read` can access only read endpoints
Failure responses:
- Missing/invalid bearer header: `401`
- Unknown token: `401`
- Insufficient permission: `403`
## 7. HTTP API Specification
Base routes:
- Health: `/health`
- Auth: `/auth`
- Portfolio: `/portfolios`
- Users: `/users`
### 7.1 Health Endpoint
- `GET /health`
- Auth: none
- Response `200`:
```json
{"status": "ok"}
```
### 7.2 Portfolio Endpoints
#### List Portfolios
- `GET /portfolios`
- Auth: `read`
- Query params:
- `broker` (optional string)
- `status_filter` (optional string)
- Response `200`: array of `PortfolioListItem`
#### Get Portfolio Status
- `GET /portfolios/{portfolio_id}`
- Auth: `read`
- Query params:
- `recent_trades` (int, default `5`)
- Response `200`: `PortfolioDetailResponse`
#### Create Portfolio
- `POST /portfolios`
- Auth: `trade-write`
- Body: `CreatePortfolioRequest`
- Response `201`: `PortfolioDetailResponse`
#### Update Portfolio
- `PATCH /portfolios/{portfolio_id}`
- Auth: `trade-write`
- Body: `UpdatePortfolioRequest`
- Response `200`: `PortfolioDetailResponse`
#### Buy
- `POST /portfolios/{portfolio_id}/buy`
- Auth: `trade-write`
- Body: `BuyRequest`
- Response `200`: `PortfolioDetailResponse`
#### Sell
- `POST /portfolios/{portfolio_id}/sell`
- Auth: `trade-write`
- Body: `SellRequest`
- Response `200`: `PortfolioDetailResponse`
#### Invest
- `POST /portfolios/{portfolio_id}/invest`
- Auth: `trade-write`
- Body: `InvestRequest`
- Response `200`: `PortfolioDetailResponse`
#### Transfer
- `POST /portfolios/{portfolio_id}/transfer`
- Auth: `trade-write`
- Body: `TransferRequest`
- Response `200`: `PortfolioDetailResponse`
#### Reset
- `POST /portfolios/{portfolio_id}/reset`
- Auth: `trade-write`
- Body: `ResetPortfolioRequest`
- Response `200`: `PortfolioDetailResponse`
#### Delete
- `DELETE /portfolios/{portfolio_id}`
- Auth: `admin`
- Query params:
- `keep_ledger` (bool, default `false`)
- Response `204`: no content
#### Create Comment
- `POST /portfolios/{portfolio_id}/comments`
- Auth: `trade-write`
- Body: `CommentCreateRequest` (`content: str`)
- Response `201`: `CommentResponse`
- Response `404`: portfolio not found
#### List Comments
- `GET /portfolios/{portfolio_id}/comments`
- Auth: `read`
- Response `200`: array of `CommentResponse`, sorted by `created_at DESC`
- Response `404`: portfolio not found
#### Update Comment
- `PATCH /portfolios/{portfolio_id}/comments/{comment_id}`
- Auth: `trade-write`
- Body: `CommentUpdateRequest` (`content: str`)
- Response `200`: `CommentResponse`
- Response `404`: portfolio or comment not found
#### Delete Comment
- `DELETE /portfolios/{portfolio_id}/comments/{comment_id}`
- Auth: `trade-write`
- Response `204`: no content
- Response `404`: portfolio or comment not found
### 7.3 Strategy Set Endpoints
#### List Strategy Sets
- `GET /strategy-sets`
- Auth: `read`
- Response `200`: array of `StrategySetResponse`
#### Create Strategy Set
- `POST /strategy-sets`
- Auth: `trade-write`
- Body: `CreateStrategySetRequest`
- Response `201`: `StrategySetResponse`
Note: Uses `get_or_create_strategy_set` logic, so if (name, config) pair already exists, returns existing strategy set.
#### Update Strategy Set
- `PATCH /strategy-sets/{strategy_set_id}`
- Auth: `trade-write`
- Body: `UpdateStrategySetRequest`
- Response `200`: `StrategySetResponse`
Note: Since strategy sets are identified by (name, config) uniqueness, updating may result in a new strategy set ID if name or config changes.
#### Delete Strategy Set
- `DELETE /strategy-sets/{strategy_set_id}`
- Auth: `trade-write`
- Response `204`: no content
- Response `409`: Conflict if strategy set is in use by any portfolio
### 7.4 Example Requests and Responses
- See `docs/api-examples.md` for maintained request/response examples.
### 7.5 Auth Endpoints
#### Login
- `POST /auth/login`
- Auth: none
- Body: `LoginRequest`
- Response `200`: `TokenResponse`
- Response `401`: invalid username/password
#### List Current Tokens
- `GET /auth/tokens`
- Auth: `read` or higher with DB-issued token/session
- Response `200`: array of `AuthTokenResponse`
#### Create Client Token
- `POST /auth/tokens`
- Auth: `read` or higher with DB-issued token/session
- Body: `CreateClientTokenRequest`
- Response `201`: `IssuedClientTokenResponse`
- Response `403`: requested token permission exceeds current permission
#### Revoke Client Token
- `DELETE /auth/tokens/{token_id}`
- Auth: `read` or higher with DB-issued token/session
- Response `204`: no content
- Response `404`: token not found
#### Create Bot Principal
- `POST /auth/bots`
- Auth: `admin`
- Body: `CreateBotPrincipalRequest`
- Response `201`: `BotPrincipalResponse`
#### List Bot Principals
- `GET /auth/bots`
- Auth: `admin`
- Query params:
- `is_active` (optional bool)
- Response `200`: array of `BotPrincipalResponse`
#### Get Bot Principal
- `GET /auth/bots/{bot_id}`
- Auth: `admin`
- Response `200`: `BotPrincipalResponse`
- Response `404`: bot not found
#### Deactivate Bot Principal
- `DELETE /auth/bots/{bot_id}`
- Auth: `admin`
- Response `200`: `BotPrincipalResponse`
- Behavior: deactivates bot and revokes all active tokens linked to the bot
#### Issue Bot Token
- `POST /auth/bots/{bot_id}/tokens`
- Auth: `admin`
- Body: `CreateBotTokenRequest`
- Response `201`: `IssuedBotTokenResponse`
#### List Bot Tokens
- `GET /auth/bots/{bot_id}/tokens`
- Auth: `admin`
- Response `200`: array of `AuthTokenResponse`
#### Revoke Bot Token
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`
- Auth: `admin`
- Response `204`: no content
### 7.6 User Endpoints
All user management endpoints are admin-only.
#### List Users
- `GET /users`
- Auth: `admin`
- Response `200`: array of `UserResponse`
#### Get User
- `GET /users/{user_id}`
- Auth: `admin`
- Response `200`: `UserResponse`
- Response `404`: user not found
#### Create User
- `POST /users`
- Auth: `admin`
- Body: `CreateUserRequest`
- Response `201`: `UserResponse`
- Response `409`: duplicate username or email
Bootstrap rule:
- If DB-backed persistence is enabled and both `PORTFOLIOHUB_BOOTSTRAP_USERNAME` and `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` are configured, application startup creates the bootstrap admin user only when the `users` table is empty.
#### Update User
- `PATCH /users/{user_id}`
- Auth: `admin`
- Body: `UpdateUserRequest`
- Response `200`: `UserResponse`
- Response `404`: user not found
- Response `409`: duplicate username or email
#### Delete User
- `DELETE /users/{user_id}`
- Auth: `admin`
- Response `204`: no content
- Response `404`: user not found
## 8. Schema Specification
### 8.1 Request Models
`CreatePortfolioRequest`
- `portfolio_id: str`
- `broker: str = "kis"`
- `status: str = "enabled"`
- `name: str | null` (when null, defaults to `symbol`)
- `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` (all fields optional)
- `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`
`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`
`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`
- `price: float > 0`
- `fee: float >= 0`
- `tax: float >= 0`
- `note: str = ""`
`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`
- `content: str`
`CommentUpdateRequest`
- `content: str`
`CreateStrategySetRequest`
- `name: str`
- `config: str`
`UpdateStrategySetRequest` (all fields optional)
- `name: str | null`
- `config: str | null`
### 8.2 Response Models
`PortfolioListItem`
- Includes portfolio summary fields and 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`
`LedgerEntryResponse`
- Snapshot-oriented ledger response including balances after each action
`ErrorResponse`
- `detail: str`
`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`
## 9. CLI Specification
Global option:
- `--format [table|json]` (default: `table`)
Commands:
- `portfoliohub init-db [--maintenance-db postgres]`
- `portfoliohub migrate-db`
- `portfoliohub health-check`
Portfolio subcommands:
- `portfoliohub portfolio list [--broker ...] [--status ...]`
- `portfoliohub portfolio status <portfolio_id> [--recent 5]`
- `portfoliohub portfolio create <portfolio_id> --symbol ... [--name ...] [options] [--force]`
- `portfoliohub portfolio update <portfolio_id> [options] [--force]`
- `portfoliohub portfolio buy <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio sell <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio invest <portfolio_id> --type <deposit|profit|withdraw> --amount ... [options] [--force]`
- `portfoliohub portfolio transfer <portfolio_id> --direction <to-buffer|from-buffer> --amount ... [options] [--force]`
- `portfoliohub portfolio reset <portfolio_id> [--infer-symbol ...] [--force]`
- `portfoliohub portfolio delete <portfolio_id> [--keep-ledger] [--force]`
Portfolio comment subcommands:
- `portfoliohub portfolio comment add <portfolio_id> --content <text>`
- `portfoliohub portfolio comment list <portfolio_id>`
- `portfoliohub portfolio comment update <portfolio_id> <comment_id> --content <text> [--force]`
- `portfoliohub portfolio comment delete <portfolio_id> <comment_id> [--force]`
Strategy subcommands:
- `portfoliohub strategy list`
- `portfoliohub strategy create --name ... --config ...`
- `portfoliohub strategy update <strategy_set_id> [--name ...] [--config ...]`
- `portfoliohub strategy delete <strategy_set_id> [--force]`
Confirmation behavior:
- Mutating portfolio commands request confirmation by default.
- `--force` bypasses confirmation prompts.
## 10. Persistence and Migration Specification
Repository selection:
- If `PORTFOLIOHUB_DB_ENABLED=false`: an unimplemented placeholder repository raises `FeatureNotReadyError` on access.
- If `PORTFOLIOHUB_DB_ENABLED=true`: `PostgresPortfolioRepository` is used.
Migration commands:
- `init-db`: create target DB if missing, then apply Alembic `head`
- `migrate-db`: apply Alembic `head` to existing target DB
Migration source of truth:
- `migrations/versions`
DB schema specification:
- Detailed table/column/index constraints are documented in `docs/db-schema-spec.md`.
- The documentation is generated from and must stay consistent with Alembic revisions under `migrations/versions`.
## 11. Error Handling
Application-level errors are translated through registered exception handlers.
Typical error classes include:
- authorization failures (`401`, `403`)
- domain/repository-level failures surfaced by service operations
- feature-not-ready errors when DB mode is disabled but persistence is required
## 12. CORS Policy
CORS middleware is enabled with:
- `allow_origins` from configured origins list
- `allow_credentials = true`
- `allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]`
- `allow_headers = ["Authorization", "Content-Type"]`
## 13. Testing Specification
Test framework:
- `pytest`
Configured behavior:
- test paths: `tests`
- recursion excluded: `temp`, `.git`, `.venv`
Current test modules include API, auth, CLI, and domain service coverage.
## 14. Versioning and Compatibility
- Current package version: `0.1.0`
- API version exposure follows application version setting (`app_version`)
- Minimum Python version: `3.11`
## 15. Future Extension Points
- Redis-backed cache/session integration
- Loki logging pipeline integration
- Additional brokers and order-execution adapters
- Enhanced operational/admin endpoints
+663
View File
@@ -0,0 +1,663 @@
# API Contracts
- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`
## 공통 원칙
- 응답은 JSON 기반으로 반환한다.
- 인증이 필요한 엔드포인트는 `Authorization: Bearer <token>`을 사용한다.
- 권한 계층은 `read` < `trade-write` < `admin`이다.
- 에러 응답은 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`
- 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`
- 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",
"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`
- 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(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.
+113
View File
@@ -0,0 +1,113 @@
# Architecture Overview
- Related REQ: `REQ-003`
## 목표
`portfolio-hub`는 포트폴리오 상태 관리 기능을 API와 CLI에 동일하게 제공하고, 서비스/도메인/리포지토리 계층 분리를 통해 변경 비용을 낮춘다.
## 개요와 범위
런타임/구성요소 기준:
- Runtime: Python 3.11+
- API framework: FastAPI
- CLI framework: Typer
- DB driver: Psycopg v3
- Migration: Alembic
주요 목표:
- 포트폴리오 lifecycle 관리(create/update/delete/reset)
- 거래 및 잔고 영향 액션 기록(buy/sell/invest/transfer)
- API와 CLI에서 동일한 상태/ledger 조회 제공
- 공유 service/repository 계층을 통한 동작 일관성 유지
현재 범위:
- 인증된 포트폴리오 조회/변경 작업
- 관리자 기반 사용자 CRUD
- 권한 계층(read, trade-write, admin)
- feature flag 기반 PostgreSQL/Timescale 저장소
- DB bootstrap 및 migration 운영 명령
현재 비목표 / 제한:
- production 용도 메모리 fallback repository 미구현
- 외부 broker 주문 실행 미포함
- Redis/Loki 관측성 경로는 완전한 runtime wiring 미완료
## 상위 구성 요소
| 컴포넌트 | 책임 |
| --- | --- |
| `app/main.py` | FastAPI 앱 생성, 라우터 등록, 설정 초기화 |
| `app/api/` | 인증/헬스/포트폴리오/전략셋/사용자 라우트 |
| `app/cli/` | 운영용 명령 인터페이스와 확인 프롬프트 |
| `app/services/` | API/CLI 공통 유스케이스 수행 |
| `app/domain/` | 포트폴리오 거래 규칙, 밸런스 계산 |
| `app/repositories/` | 저장소 추상화 및 Postgres 구현 |
| `app/db/`, `migrations/` | DB 초기화 및 스키마 버전 관리 |
## 엔트리포인트
HTTP 서비스:
- App factory: `app.main:create_app`
- ASGI app: `app.main:app`
- OpenAPI docs: `/docs`
- ReDoc: `/redoc`
CLI:
- Console script: `portfoliohub`
- Entry function: `app.cli.main:main`
## 기본 런타임 흐름
### 1. API 기반 거래 처리
1. 라우트에서 요청 바디와 권한을 검증한다.
2. 서비스 계층에서 도메인 규칙(수량/금액/상태)을 평가한다.
3. 리포지토리에서 포트폴리오/원장 데이터를 갱신한다.
4. 응답 스키마로 직렬화해 반환한다.
### 2. CLI 기반 동일 작업 처리
1. CLI 명령이 인자를 파싱하고 확인 프롬프트를 처리한다.
2. API와 동일한 서비스 메서드를 호출한다.
3. 결과를 터미널 친화 포맷으로 출력한다.
### 3. DB 활성화 경로
1. `PORTFOLIOHUB_DB_ENABLED=true` 확인
2. DB 연결 정보(`TIMESCALEDB_*`) 로딩
3. Postgres 리포지토리 선택
4. 필요 시 Alembic 마이그레이션 후 서비스 기동
## 설계 원칙
- **Service Reuse First**: API/CLI는 동일 service 계층을 재사용한다.
- **Contract Stability**: 외부 계약은 `app/schemas/`와 기술 문서에서 일관성을 유지한다.
- **Persistence Isolation**: 영속성 구현 세부사항은 repository 계층에 격리한다.
- **Fail Fast Config**: 필수 설정 누락은 조기 실패로 드러낸다.
## 테스트와 호환성 기준
테스트 기준:
- 테스트 프레임워크는 `pytest`를 사용한다.
- 주요 테스트 범위는 API, auth, CLI, domain service를 포함한다.
- 재귀 제외 경로는 `temp`, `.git`, `.venv`를 사용한다.
버전/호환성 기준:
- 현재 패키지 버전: `0.1.0`
- API 버전 노출은 앱 버전 설정(`app_version`)을 따른다.
- 최소 Python 버전: `3.11`
## 향후 확장 포인트
- Redis 기반 cache/session 통합
- Loki logging pipeline 통합
- 추가 broker 및 order-execution adapter
- 운영/admin 확장 엔드포인트
## Open Implementation Points
- `REQ-002` 태그 검색 구현 시 조회 성능 요구를 만족하는 인덱스/쿼리 전략을 확정한다.
- 관측성(메트릭/로그)의 표준 이벤트 스키마를 별도 문서로 확장할지 결정한다.
@@ -0,0 +1,54 @@
# Auth and Token Lifecycle
- Related REQ: N/A
## 목표
정적 토큰과 DB 발급 토큰을 병행하는 현재 인증 모델의 동작 기준을 명확히 한다.
## 권한 모델
| Permission | 허용 범위 |
| --- | --- |
| `read` | 조회 계열 API |
| `trade-write` | 조회 + 포트폴리오 변경 API |
| `admin` | 전체 API + 사용자/봇 관리 |
## 인증 방식
- 보호된 API는 `Authorization: Bearer <token>` 헤더를 사용한다.
- 인증 소스는 정적 토큰과 DB 저장 토큰을 병행한다.
권한 계층:
- `admin`은 전체 엔드포인트 접근 가능
- `trade-write`는 `trade-write`와 `read` 범위 접근 가능
- `read`는 조회 엔드포인트만 접근 가능
## 인증 소스
- 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS`
- DB 저장 토큰: 로그인 세션/클라이언트 토큰/봇 토큰
## 토큰 라이프사이클
1. 사용자 로그인(`POST /auth/login`) 또는 토큰 발급 API 호출
2. 토큰 해시 저장 및 prefix 관리
3. 요청 처리 시 해시 비교와 만료/폐기 상태 검증
4. `DELETE /auth/tokens/{token_id}` 또는 봇 비활성화로 토큰 폐기
## 실패 응답 기준
- bearer 헤더 누락 또는 형식 오류: `401`
- 알 수 없는 토큰: `401`
- 권한 부족: `403`
## 부트스트랩 사용자 규칙
- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`과 `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`가 설정되고 users 테이블이 비어 있으면 초기 admin을 1회 생성한다.
- 이미 사용자가 존재하면 추가 생성하지 않는다.
## Validation Notes
- 만료된 토큰은 항상 인증 실패로 처리해야 한다.
- 비활성 봇(`is_active=false`)의 토큰은 거부되어야 한다.
- 권한 계층 승격 우회가 없는지 API/CLI 양쪽에서 검증한다.
## Open Implementation Points
- 정적 토큰과 DB 토큰의 우선순위/병합 정책을 명문화할지 결정한다.
- 감사 로그(audit trail)에 토큰 발급/폐기 이벤트를 표준 포맷으로 남길지 확정한다.
+193
View File
@@ -0,0 +1,193 @@
# Deployment and Operations
- Related REQ: N/A
## 목표
PortfolioHub를 Linux 환경에서 안정적으로 배포하고 HTTPS 종단, 프로세스 복구, 기본 점검 경로를 표준화한다.
## 사전 조건
- public IP를 가진 Linux/Ubuntu 서버
- 해당 IP를 가리키는 도메인 DNS `A` record
- 방화벽에서 `80`, `443` 포트 개방
## 권장 배포 토폴로지
- App: `uvicorn app.main:app`
- Reverse proxy/TLS: Caddy
- Process manager: systemd
- 선택: Docker Compose + Caddy sidecar
## Linux Direct Run 기준
1. Python 가상환경 생성 후 패키지를 설치한다.
2. 환경 변수 또는 `.env`를 구성한다.
3. `uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2`를 실행한다.
4. `GET /health`를 확인한다.
## Caddy 설치와 구성
Ubuntu/Debian 설치 예시:
```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
설치 검증:
```bash
caddy version
```
기본 Caddyfile:
```text
your-domain.com {
reverse_proxy localhost:8000
}
```
동작 기준:
- TLS 인증서 자동 발급/갱신
- `80`에서 `443`으로 HTTP redirect
- 외부 요청을 내부 `uvicorn` 프로세스(`127.0.0.1:8000`)로 전달
적용 후 reload:
```bash
sudo systemctl reload caddy
```
## systemd 운영 기준
- 서비스 계정 권한 최소화
- `Restart=on-failure`
- 로그 수집 경로(journalctl 또는 외부 수집기) 확보
예시 unit 파일:
```ini
[Unit]
Description=PortfolioHub API
After=network.target
[Service]
User=www-data
WorkingDirectory=/opt/portfoliohub
EnvironmentFile=/opt/portfoliohub/.env
ExecStart=/opt/portfoliohub/.venv/bin/uvicorn app.main:app \
--host 127.0.0.1 --port 8000 --workers 2
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
등록/기동:
```bash
sudo systemctl daemon-reload
sudo systemctl enable portfoliohub
sudo systemctl start portfoliohub
```
## HTTPS/Caddy 운영 기준
- 도메인 DNS가 서버 IP를 가리켜야 한다.
- 포트 `80/443`을 개방한다.
- Caddyfile에서 앱을 `reverse_proxy`로 연결한다.
- 브라우저 기반 frontend가 있으면 `PORTFOLIOHUB_ALLOWED_ORIGINS`에 정확한 HTTPS origin을 지정한다.
예시:
```bash
PORTFOLIOHUB_ALLOWED_ORIGINS=https://app.example.com,https://www.example.com
```
## Docker Compose 운영 기준
- 앱 컨테이너는 내부 포트만 expose하고 외부 종단은 Caddy가 담당한다.
- `caddy_data`, `caddy_config` 볼륨을 유지해 인증서 상태를 보존한다.
- 배포 후 `docker compose ps`, `curl /health`로 기본 상태를 확인한다.
예시 compose:
```yaml
services:
app:
build: .
expose:
- "8000"
environment:
- PORTFOLIOHUB_ALLOWED_ORIGINS=https://your-domain.com
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
```
compose 네트워크 안에서는 `reverse_proxy app:8000`을 사용한다.
기동:
```bash
docker compose up -d
```
## 방화벽 설정
UFW:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
```
firewalld:
```bash
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
```
## 배포 검증
HTTPS health check:
```bash
curl https://your-domain.com/health
```
운영 상태 확인:
```bash
sudo systemctl status caddy
sudo journalctl -u caddy -f
```
## Validation Notes
- HTTPS 엔드포인트에서 `/health`, `/docs` 접근이 가능해야 한다.
- DB 사용 배포에서는 마이그레이션 적용 후 앱을 기동한다.
- CORS 오리진 설정과 실제 프론트엔드 도메인이 일치해야 한다.
## Open Implementation Points
- blue/green 또는 rolling update 같은 무중단 배포 전략 도입 여부를 확정한다.
- Loki/metrics 통합 운영 절차를 별도 런북으로 분리할지 결정한다.
+49
View File
@@ -0,0 +1,49 @@
# portfolio-hub Technical Specification Index
## 목적
- 이 디렉토리는 `portfolio-hub`의 **구현 상세 설계(How)** 를 관리한다.
- 승인 기준과 외부 요구사항의 source of truth는 `docs/SRS/`이며, 기술 문서는 해당 요구사항을 구현하기 위한 내부 설계를 정리한다.
## 문서 사용 원칙
1. 외부 계약, acceptance criteria, 상태 전이는 `docs/SRS/`에서 관리한다.
2. 내부 모듈 책임, 시퀀스, 데이터 흐름, 예시 payload는 `docs/technical/`에서 관리한다.
3. 기술 문서가 SRS와 충돌하면, 별도 owner 결정 전까지 **SRS를 우선**한다.
4. 기술 문서를 수정할 때는 관련 REQ ID를 함께 표시해 traceability를 유지한다.
## 문서 목록
| 문서 | 목적 | 연관 REQ |
| --- | --- | --- |
| `project-structure-baseline.md` | 저장소 baseline 디렉토리 구조와 코드/문서 배치 기준 | N/A |
| `python-runtime-and-packaging.md` | Python 런타임과 `pyproject.toml` 기반 패키징 기준 | N/A |
| `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` |
| `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` |
| `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A |
| `management-cli-operations.md` | `portfoliohub` CLI 명령 책임, 종료코드, 운영 플로우 | N/A |
| `deployment-and-operations.md` | Linux systemd, Caddy reverse proxy, Docker Compose 운영 절차 | N/A |
## 연관 REQ 매핑 표
| REQ ID | Technical Docs |
| --- | --- |
| `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` |
| `REQ-002` | `api-contracts.md` |
| `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` |
## 권장 작성 순서
1. `project-structure-baseline.md`
2. `python-runtime-and-packaging.md`
3. `settings-and-env-loading.md`
4. `architecture-overview.md`
5. `api-contracts.md`
6. `persistence-and-migrations.md`
7. `auth-and-token-lifecycle.md`
8. `management-cli-operations.md`
9. `deployment-and-operations.md`
## 추적 규칙
- 각 기술 문서는 상단에 관련 REQ를 명시한다.
- 구현 착수 전에는 해당 문서의 open issue가 남아 있는지 확인한다.
- 운영 중 발견된 차이는 technical 문서에 먼저 반영하고, 외부 계약 변화가 있으면 SRS 업데이트 여부를 별도로 판정한다.
@@ -0,0 +1,84 @@
# Management CLI Operations
- Related REQ: N/A
## 목표
`portfoliohub` CLI를 운영 자동화와 수동 점검 모두에 안전하게 사용할 수 있도록 명령 책임과 사용 규칙을 정의한다.
## 명령 그룹
| 그룹 | 주요 명령 | 목적 |
| --- | --- | --- |
| DB 운영 | `init-db`, `migrate-db`, `health-check` | 스키마 적용 및 연결 상태 점검 |
| 사용자 관리 | `user list/get/create/update/delete` | 운영 계정 라이프사이클 |
| 포트폴리오 조회 | `portfolio list`, `portfolio status` | 상태 확인 |
| 포트폴리오 변경 | `portfolio create/update/buy/sell/invest/transfer/reset/delete` | 거래/자금/상태 변경 |
## 인터페이스 명세
전역 옵션:
- `--format [table|json]` (기본값: `table`)
공통 명령:
- `portfoliohub init-db [--maintenance-db postgres]`
- `portfoliohub migrate-db`
- `portfoliohub health-check`
포트폴리오 하위 명령:
- `portfoliohub portfolio list [--broker ...] [--status ...]`
- `portfoliohub portfolio status <portfolio_id> [--recent 5]`
- `portfoliohub portfolio create <portfolio_id> --symbol ... [--name ...] [options] [--force]`
- `portfoliohub portfolio update <portfolio_id> [options] [--force]`
- `portfoliohub portfolio buy <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio sell <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio invest <portfolio_id> --type <deposit|profit|withdraw> --amount ... [options] [--force]`
- `portfoliohub portfolio transfer <portfolio_id> --direction <to-buffer|from-buffer> --amount ... [options] [--force]`
- `portfoliohub portfolio reset <portfolio_id> [--infer-symbol ...] [--force]`
- `portfoliohub portfolio delete <portfolio_id> [--keep-ledger] [--force]`
포트폴리오 댓글 하위 명령:
- `portfoliohub portfolio comment add <portfolio_id> --content <text>`
- `portfoliohub portfolio comment list <portfolio_id>`
- `portfoliohub portfolio comment update <portfolio_id> <comment_id> --content <text> [--force]`
- `portfoliohub portfolio comment delete <portfolio_id> <comment_id> [--force]`
전략셋 하위 명령:
- `portfoliohub strategy list`
- `portfoliohub strategy create --name ... --config ...`
- `portfoliohub strategy update <strategy_set_id> [--name ...] [--config ...]`
- `portfoliohub strategy delete <strategy_set_id> [--force]`
## 실행 원칙
- 쓰기 명령은 기본적으로 확인 프롬프트를 띄운다.
- 자동화 스크립트에서는 `--force`를 사용해 비대화식 실행을 보장한다.
- DB 타깃 실행 시 `PORTFOLIOHUB_DB_ENABLED=true`와 `TIMESCALEDB_*`를 명시한다.
## 예시 시나리오
1. 신규 환경 준비
- `portfoliohub init-db`
- `portfoliohub health-check`
2. 일상 운영
- `portfoliohub portfolio list`
- `portfoliohub portfolio status <portfolio_id>`
3. 배포 후 검증
- `portfoliohub user list`
- 주요 API smoke test와 결과 비교
## 오류/종료코드 가이드
- `0`: 정상 종료
- `1`: 입력/검증/런타임 오류
- 재시도 가능 오류(예: 일시적 DB 연결 실패)는 메시지에 원인을 명확히 표기한다.
확인 동작:
- 변경 명령은 기본적으로 확인 프롬프트를 요청한다.
- `--force`를 지정하면 확인 프롬프트를 생략한다.
## Open Implementation Points
- JSON 출력 모드(`--json`)를 도입해 자동화 친화성을 높일지 결정한다.
- 대량 작업(batch) 명령 추가 시 실패 항목 보고 포맷을 표준화한다.
@@ -0,0 +1,279 @@
# Persistence and Migrations
- Related REQ: `REQ-001`, `REQ-003`
## 목표
DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다.
## 저장소 선택 규칙
- `PORTFOLIOHUB_DB_ENABLED=true`이면 Postgres 리포지토리를 사용한다.
- DB 비활성 모드는 개발/부트스트랩 목적의 제한적 동작으로 간주한다.
비활성 동작 기준:
- `PORTFOLIOHUB_DB_ENABLED=false`이면 미구현 placeholder repository가 `FeatureNotReadyError`를 발생시킨다.
## 데이터 모델 핵심 영역
| 테이블 | 역할 |
| --- | --- |
| `portfolios` | 현재 포트폴리오 상태 |
| `portfolio_ledger` | 거래/자금이동 이력 스냅샷 |
| `portfolio_comments` | 포트폴리오 코멘트 이력 (`REQ-001`) |
| `strategy_sets` | 전략 설정 스냅샷 |
| `users` | 운영 사용자 |
| `auth_tokens` | 세션/클라이언트/봇 토큰 |
| `bot_principals` | 외부 봇 주체 |
## 스키마 문서 기준
- Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260410_0009`
- 상세 테이블/컬럼/인덱스 제약은 이 문서가 Alembic revision과 동기화된 기준 문서다.
## 마이그레이션 운영 흐름
1. 신규 스키마 변경을 Alembic revision으로 추가한다.
2. `migrations/versions/`에 revision 파일을 저장한다.
3. 본 문서에 revision ID와 스키마 변경 내역을 반영한다.
4. `portfoliohub migrate-db` 또는 Alembic `upgrade head`로 적용한다.
## 명령 기준
- 초기 DB 준비: `portfoliohub init-db`
- 버전 업그레이드: `portfoliohub migrate-db`
- 상태 점검: `portfoliohub health-check`
명령 동작:
- `init-db`: target DB가 없으면 생성 후 Alembic `head` 적용
- `migrate-db`: 기존 target DB에 Alembic `head` 적용
## 스키마 기준
### Conventions
- 데이터 타입 표기는 SQLAlchemy/Alembic 선언명을 따른다.
- `NULL`은 nullable을 의미한다.
- Default는 DB-level `server_default`를 의미한다.
- FK 제약이 없어도 logical relationship column은 문서화한다.
### Tables
#### users
Purpose:
- 운영 사용자 레코드와 향후 identity mapping 기준 저장
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| id | Text | NO | - | Primary Key |
| username | Text | NO | - | Unique username |
| email | Text | YES | - | Unique when present |
| permission | Text | NO | `read` | User permission level |
| is_active | Boolean | NO | TRUE | Whether login is allowed |
| password_hash | Text | YES | - | Scrypt password hash |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_login_at | DateTime(timezone=True) | YES | - | Last successful login timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_users_username` on (`username`)
- Unique: `uq_users_email` on (`email`)
#### auth_tokens
Purpose:
- DB 발급 session/client/bot token 저장
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| id | Text | NO | - | Primary Key |
| user_id | Text | NO | - | Logical user reference |
| name | Text | NO | - | Human-readable token name |
| token_prefix | Text | NO | - | Display prefix |
| token_hash | Text | NO | - | Unique SHA-256 token hash |
| permission | Text | NO | - | Effective token permission |
| token_type | Text | NO | - | `session`, `client`, or `bot` |
| bot_principal_id | Text | YES | - | Logical reference to `bot_principals.id` |
| expires_at | DateTime(timezone=True) | YES | - | Expiration time |
| last_used_at | DateTime(timezone=True) | YES | - | Last successful use |
| revoked_at | DateTime(timezone=True) | YES | - | Revocation time |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_auth_tokens_token_hash` on (`token_hash`)
#### bot_principals
Purpose:
- 외부 bot identity와 권한 ceiling 관리
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| id | Text | NO | - | Primary Key |
| name | Text | NO | - | Unique bot name |
| permission | Text | NO | - | Bot permission ceiling |
| description | Text | YES | - | Optional description |
| is_active | Boolean | NO | TRUE | False면 bot token 거부 |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| deactivated_at | DateTime(timezone=True) | YES | - | Deactivation time |
Constraints:
- Primary key: `id`
- Unique: `uq_bot_principals_name` on (`name`)
#### strategy_sets
Purpose:
- 정규화된 전략 설정 snapshot 저장
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| id | Text | NO | - | Primary Key |
| name | Text | NO | - | Strategy name, unique pair 일부 |
| config | Text | NO | `""` | Strategy config payload |
| nick | Text | NO | - | Unique nickname |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_strategy_sets_name_config` on (`name`, `config`)
- Unique: `uq_strategy_sets_nick` on (`nick`)
#### portfolios
Purpose:
- 현재 포트폴리오 상태와 strategy linkage 저장
Current baseline note:
- 아래 컬럼은 revision `20260403_0008`까지의 마이그레이션 기준이다.
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| portfolio_id | Text | NO | - | Primary Key |
| broker | Text | NO | `kis` | Broker code |
| status | Text | NO | `enabled` | Portfolio status |
| name | Text | NO | - | Asset name |
| symbol | Text | NO | - | Trading symbol |
| cash_balance | Float | NO | - | Current cash balance |
| asset_quantity | Float | NO | - | Current asset quantity |
| asset_price | Float | NO | 0 | Current price snapshot |
| profit_buffer | Float | NO | 0 | Profit buffer amount |
| active_strategy_id | Text | YES | - | Logical reference to `strategy_sets.id` |
| avabile_strategies | Text[] | YES | - | Nullable array of `strategy_sets.id` |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_trade_at | DateTime(timezone=True) | YES | - | Last trade time |
Constraints:
- Primary key: `portfolio_id`
- Foreign key constraints: none in baseline
#### portfolio_ledger
Purpose:
- 포트폴리오 액션별 append-like ledger snapshot 저장
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` |
| recorded_at | DateTime(timezone=True) | NO | - | Event timestamp |
| action | Text | NO | - | Action type |
| price | Float | NO | - | Trade/valuation unit price |
| quantity | Float | NO | - | Quantity delta |
| fee_amount | Float | NO | 0 | Fees |
| tax_amount | Float | NO | 0 | Taxes |
| cash_balance_after | Float | NO | - | Post-action cash |
| asset_quantity_after | Float | NO | - | Post-action asset quantity |
| total_value_after | Float | NO | - | Post-action total value |
| profit_buffer_after | Float | NO | 0 | Post-action profit buffer |
| strategy_set_id | Text | NO | `legacy` | Logical strategy set reference |
| note | Text | NO | `""` | Operator/system note |
Constraints:
- Primary key: none in baseline
- Foreign key constraints: none in baseline
#### portfolio_comments
Purpose:
- 포트폴리오별 multiline comment history 저장 (`REQ-001`)
| Column | Type | Null | Default | Constraints / Notes |
| --- | --- | --- | --- | --- |
| id | Text | NO | - | Primary Key (UUID) |
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` |
| content | Text | NO | - | Multiline comment text |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
Constraints:
- Primary key: `id`
- Foreign key constraints: none
### Indexes
| Index | Table | Columns/Expressions | Unique | Intent |
| --- | --- | --- | --- | --- |
| `ix_users_created_at` | `users` | `created_at` | no | recency listing |
| `idx_auth_tokens_user_created` | `auth_tokens` | `user_id`, `created_at` | no | current user token listing |
| `idx_auth_tokens_hash_active` | `auth_tokens` | `token_hash` | no | bearer token lookup |
| `idx_auth_tokens_bot_created` | `auth_tokens` | `bot_principal_id`, `created_at` | no | bot token listing |
| `idx_bot_principals_active_created` | `bot_principals` | `is_active`, `created_at` | no | active/inactive bot filtering |
| `idx_portfolio_ledger_portfolio_time` | `portfolio_ledger` | `portfolio_id`, `recorded_at DESC` | no | recent ledger query |
| `idx_portfolios_broker_status_updated` | `portfolios` | `broker`, `status`, `updated_at DESC` | no | broker/status recency filtering |
| `idx_portfolio_comments_portfolio_created` | `portfolio_comments` | `portfolio_id`, `created_at DESC` | no | reverse chronological comments |
Conditional migration behavior:
- `timescaledb` extension 존재 시 `create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)` 실행
- 이 경우 `portfolio_ledger`는 hypertable로 동작한다.
## Integrity Notes
현재 baseline은 DB-level FK constraint를 의도적으로 두지 않는다.
- logical relation은 application/repository logic이 관리한다.
- 향후 FK constraint를 추가하면 migration ID와 함께 본 문서를 갱신한다.
- application layer cascade:
- 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다.
## Validation Notes
- 마이그레이션은 빈 DB와 기존 DB 모두에서 연속 적용 가능해야 한다.
- 파괴적 변경(drop/rename)은 백필 또는 호환 단계 계획 없이 직접 반영하지 않는다.
- 문서 동기화 게이트(`scripts/check_docs_sync.py`)를 통과해야 한다.
## Revision Mapping
| Revision ID | File | Summary |
| --- | --- | --- |
| 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable |
| 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name` to `name`, `strategy_config` to `config`, add unique `nick` |
| 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded strategy fields, add `active_strategy_id` and `avabile_strategies` |
| 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index |
| 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add user auth fields and `auth_tokens` table |
| 20260403_0006 | migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py | Add `bot_principals` and bot token linkage |
| 20260403_0007 | migrations/versions/20260403_0007_bot_principal_permission_only.py | Remove `owner_user_id` and enforce bot-local permission model |
| 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 |
## Open Implementation Points
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다.
@@ -0,0 +1,39 @@
# Project Structure Baseline
- Related REQ: N/A
## 목표
`portfolio-hub` 구현 및 유지보수 시 디렉토리 책임을 명확히 하여 API/CLI/도메인/저장소 변경의 영향 범위를 예측 가능하게 유지한다.
## Target Baseline Structure
| 경로 | 책임 | 구현 메모 |
| --- | --- | --- |
| `docs/SRS/` | 외부 계약, 승인 기준, REQ 상태 관리 | requirement source of truth |
| `docs/technical/` | 구현 상세 설계와 운영 메모 | SRS를 재정의하지 않음 |
| `app/api/` | HTTP 라우트, 요청/응답 경계 | 서비스 계층 호출 전담 |
| `app/cli/` | Typer CLI 명령과 입출력 | 서비스 계층 재사용 |
| `app/services/` | 유스케이스 조합, 권한/검증 흐름 | API/CLI 공통 오케스트레이션 |
| `app/domain/` | 포트폴리오 비즈니스 규칙 | 기술 의존성 최소화 |
| `app/repositories/` | 영속성 인터페이스와 구현체 | DB feature flag 지원 |
| `app/schemas/` | API 계약 스키마(Pydantic) | 요청/응답 안정성 |
| `migrations/` | Alembic 마이그레이션 | 스키마 변경 이력 |
| `tests/` | API/도메인/CLI/계약 테스트 | 회귀 방지 게이트 |
## Root-Level Exception Rules
- 루트에는 `README.md`, `pyproject.toml`, `alembic.ini`, `.github/` 등 저장소 메타파일만 둔다.
- 실행 코드와 테스트 코드는 목적 디렉토리로 수렴시킨다.
## Documentation Responsibilities
- `README.md`: 빠른 시작, 실행 방법, 운영 필수 변수 요약
- `docs/SRS/`: 왜(Why), 무엇(What), 수락 기준
- `docs/technical/`: 어떻게(How), 모듈 경계, 예시 흐름
## Validation Notes
- 신규 모듈 추가 시 책임이 상기 경로 정의와 충돌하지 않는지 확인한다.
- API 계약 변경 시 `app/schemas/`와 `docs/technical/api-contracts.md`를 함께 동기화한다.
- 스키마 변경 시 `migrations/versions/`와 문서 업데이트를 같은 변경 단위에 포함한다.
## Open Implementation Points
- Redis/Loki 실제 운영 경로가 코드에 전면 반영되면 `app/core/` 또는 별도 infra 패키지 분리 여부를 확정한다.
- 태그 검색 기능(`REQ-002`) 구현 시 서비스/리포지토리 경계의 책임 분리를 추가 명세한다.
@@ -0,0 +1,33 @@
# Python Runtime and Packaging
- Related REQ: N/A
## 목표
Python `3.11+` 및 `pyproject.toml` 중심 패키징 기준을 유지하고, 로컬/CI/운영의 실행 환경 편차를 줄인다.
## Runtime Baseline
- 공식 지원 런타임은 Python `3.11` 이상이다.
- 로컬 개발은 `venv` 기반 실행을 기본으로 한다.
- 개발/테스트 의존성은 editable install(`pip install -e .[dev]`)로 일관화한다.
## Required Files
| 파일 | 책임 | 필수 내용 |
| --- | --- | --- |
| `pyproject.toml` | 프로젝트 메타데이터와 의존성 기준 | `requires-python >=3.11` |
| `README.md` | 설치/실행/테스트 진입점 | venv 생성 및 실행 절차 |
| `alembic.ini` | DB migration 런타임 설정 | Alembic 동작 기준 |
## Packaging Rules
- 패키징 source of truth는 `pyproject.toml`로 유지한다.
- CLI 엔트리포인트 `portfoliohub`는 패키지 설치 후 동일하게 동작해야 한다.
- 운영 이미지/환경에서도 개발과 동일한 Python major/minor를 사용한다.
## Validation Notes
- `pip install -e .[dev]` 후 `uvicorn app.main:app --reload`와 `pytest`가 실행 가능해야 한다.
- `portfoliohub --help`가 정상 출력되어야 한다.
- Alembic 명령(`upgrade head`)이 현재 마이그레이션 체인에서 실패하지 않아야 한다.
## Open Implementation Points
- 락파일 기반 재현성(`uv lock`, `poetry.lock` 등) 도입 여부는 팀 운영 정책에 따라 확정한다.
- 배포 환경 컨테이너 표준화 시 base image pinning 정책을 문서화한다.
@@ -0,0 +1,84 @@
# Settings and Env Loading
- Related REQ: N/A
## 목표
설정값 로딩 경로를 단일화하여 로컬/운영 간 동작 차이를 최소화하고, 보안 민감 값을 안전하게 주입한다.
## 설정 로딩 우선순위
1. 환경 변수
2. `.env` 파일
3. 코드 기본값
## 핵심 설정 그룹
| 그룹 | 예시 키 | 목적 |
| --- | --- | --- |
| API/CORS | `PORTFOLIOHUB_ALLOWED_ORIGINS` | 브라우저 호출 허용 오리진 제어 |
| 인증/권한 | `PORTFOLIOHUB_READ_TOKENS`, `PORTFOLIOHUB_TRADE_WRITE_TOKENS`, `PORTFOLIOHUB_ADMIN_TOKENS` | 정적 토큰 기반 접근 제어 |
| 세션/토큰 TTL | `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS` 등 | DB 발급 토큰 만료 정책 |
| Bootstrap User | `PORTFOLIOHUB_BOOTSTRAP_USERNAME`, `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | 초기 admin 사용자 생성 |
| DB 연결 | `TIMESCALEDB_*` | Postgres/Timescale 접속 정보 |
| Observability | `LOKI_*`, `REDIS_*` | 중앙 로그/캐시 운영 확장 포인트 |
## 상세 설정 명세
핵심 설정:
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins
- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`: users 테이블이 비어 있을 때 생성할 초기 username
- `PORTFOLIOHUB_BOOTSTRAP_EMAIL`: bootstrap user의 선택적 email
- `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`: 초기 admin password
- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated trade-write tokens
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
- `PORTFOLIOHUB_DB_ENABLED`: `true`일 때 DB repository 활성화
- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL(hours)
- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: client token 기본 TTL(days)
- `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS`: bot token 기본 TTL(days)
DB 활성 설정:
- `TIMESCALEDB_HOST`
- `TIMESCALEDB_PORT`
- `TIMESCALEDB_NAME`
- `TIMESCALEDB_USER`
- `TIMESCALEDB_PASSWORD`
- `TIMESCALEDB_SSLMODE`
- `TIMESCALEDB_CONNECT_TIMEOUT_SEC`
- `TIMESCALEDB_APPLICATION_NAME`
파생 설정:
- `active_database_url`은 psycopg PostgreSQL URL 포맷으로 credentials와 query options를 인코딩해 구성한다.
## CORS 정책
CORS middleware는 다음 기준으로 동작한다.
- `allow_origins`: 구성된 origins 목록 사용
- `allow_credentials = true`
- `allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]`
- `allow_headers = ["Authorization", "Content-Type"]`
브라우저 기반 frontend가 HTTPS로 접근하면 `PORTFOLIOHUB_ALLOWED_ORIGINS`에는 정확한 HTTPS origin만 명시한다.
예시:
```bash
PORTFOLIOHUB_ALLOWED_ORIGINS=https://app.example.com,https://www.example.com
```
## 운영 규칙
- 운영 환경에서는 `.env` 대신 배포 플랫폼의 환경 변수 주입을 우선한다.
- `PORTFOLIOHUB_DB_ENABLED=true`일 때 `TIMESCALEDB_*` 필수 키 누락 여부를 시작 시 검증한다.
- 민감 값은 문서/샘플에 실제 비밀 값을 기록하지 않는다.
## Validation Notes
- 필수 키 누락 시 명확한 예외 메시지로 기동 실패해야 한다.
- CORS 오리진 파싱은 공백/쉼표 변형 입력에도 안정적으로 동작해야 한다.
- 부트스트랩 계정 자동 생성은 `users` 테이블이 비어 있을 때만 수행되어야 한다.
## Open Implementation Points
- 설정 스키마를 strict mode로 고정할지(미정의 키 거부) 여부를 확정한다.
- Redis/Loki 활성화 플래그와 실패 시 동작(무시/실패)을 명시적으로 분리할지 결정한다.