refactor docs into technical specs
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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(예: 재시도 가능 매매 요청)의 키 정책을 확정한다.
|
||||
@@ -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)에 토큰 발급/폐기 이벤트를 표준 포맷으로 남길지 확정한다.
|
||||
@@ -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 통합 운영 절차를 별도 런북으로 분리할지 결정한다.
|
||||
@@ -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 활성화 플래그와 실패 시 동작(무시/실패)을 명시적으로 분리할지 결정한다.
|
||||
Reference in New Issue
Block a user