From 256b3709a0855a36d96c3a48ada38575e9706e2d Mon Sep 17 00:00:00 2001 From: Jinhwan Ko Date: Mon, 13 Apr 2026 23:20:25 +0900 Subject: [PATCH] refactor docs into technical specs --- README.md | 10 +- .../Data/REQ-003_portfolio-required-fields.md | 40 +- .../REQ-001_portfolio-comment-history.md | 38 +- .../REQ-002_portfolio-tagging-and-search.md | 38 +- docs/api-examples.md | 834 ------------------ docs/db-schema-spec.md | 332 ------- docs/deployment.md | 194 ---- docs/portfoliohub-spec.md | 697 --------------- docs/technical/api-contracts.md | 663 ++++++++++++++ docs/technical/architecture-overview.md | 113 +++ docs/technical/auth-and-token-lifecycle.md | 54 ++ docs/technical/deployment-and-operations.md | 193 ++++ docs/technical/index.md | 49 + docs/technical/management-cli-operations.md | 84 ++ docs/technical/persistence-and-migrations.md | 279 ++++++ docs/technical/project-structure-baseline.md | 39 + .../technical/python-runtime-and-packaging.md | 33 + docs/technical/settings-and-env-loading.md | 84 ++ scripts/check_docs_sync.py | 26 +- 19 files changed, 1713 insertions(+), 2087 deletions(-) delete mode 100644 docs/api-examples.md delete mode 100644 docs/db-schema-spec.md delete mode 100644 docs/deployment.md delete mode 100644 docs/portfoliohub-spec.md create mode 100644 docs/technical/api-contracts.md create mode 100644 docs/technical/architecture-overview.md create mode 100644 docs/technical/auth-and-token-lifecycle.md create mode 100644 docs/technical/deployment-and-operations.md create mode 100644 docs/technical/index.md create mode 100644 docs/technical/management-cli-operations.md create mode 100644 docs/technical/persistence-and-migrations.md create mode 100644 docs/technical/project-structure-baseline.md create mode 100644 docs/technical/python-runtime-and-packaging.md create mode 100644 docs/technical/settings-and-env-loading.md diff --git a/README.md b/README.md index 9a9eb10..95fec15 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ your-domain.com { } ``` -See **[docs/deployment.md](docs/deployment.md)** for a full guide covering: +See **[docs/technical/deployment-and-operations.md](docs/technical/deployment-and-operations.md)** for a full guide covering: - Caddy installation on Linux/Ubuntu - CORS configuration (`PORTFOLIOHUB_ALLOWED_ORIGINS`) @@ -152,10 +152,10 @@ To keep implementation and specification documents in sync, this repository prov Rules enforced: -- If files under `app/` change, `docs/portfoliohub-spec.md` must be updated in the same commit/PR. -- If files under `app/api/` or `app/schemas/` change, `docs/api-examples.md` must be updated in the same commit/PR. -- If files under `migrations/versions/` change, `docs/db-schema-spec.md` must be updated in the same commit/PR. -- Changed migration revision IDs must appear in `docs/db-schema-spec.md`. +- If files under `app/` change, at least one `docs/technical/` document must be updated in the same commit/PR. +- If files under `app/api/` or `app/schemas/` change, `docs/technical/api-contracts.md` must be updated in the same commit/PR. +- If files under `migrations/versions/` change, `docs/technical/persistence-and-migrations.md` must be updated in the same commit/PR. +- Changed migration revision IDs must appear in `docs/technical/persistence-and-migrations.md`. Enable local pre-commit check: diff --git a/docs/SRS/Data/REQ-003_portfolio-required-fields.md b/docs/SRS/Data/REQ-003_portfolio-required-fields.md index 0e52f2f..a6ad9c6 100644 --- a/docs/SRS/Data/REQ-003_portfolio-required-fields.md +++ b/docs/SRS/Data/REQ-003_portfolio-required-fields.md @@ -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: diff --git a/docs/SRS/Interface/REQ-001_portfolio-comment-history.md b/docs/SRS/Interface/REQ-001_portfolio-comment-history.md index 59e04eb..c5645d7 100644 --- a/docs/SRS/Interface/REQ-001_portfolio-comment-history.md +++ b/docs/SRS/Interface/REQ-001_portfolio-comment-history.md @@ -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 diff --git a/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md b/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md index 6d175a7..5a58a03 100644 --- a/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md +++ b/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md @@ -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: diff --git a/docs/api-examples.md b/docs/api-examples.md deleted file mode 100644 index 5ab64c1..0000000 --- a/docs/api-examples.md +++ /dev/null @@ -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 ` -- 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 " -``` - -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 " -``` - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " \ - -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 " -``` - -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 " \ - -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 " -``` - -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 " \ - -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 " -``` - -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 " \ - -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 " -``` - -### 6.4 DELETE /auth/tokens/{token_id} - -Request: - -```bash -curl -X DELETE "http://localhost:8000/auth/tokens/token-1" \ - -H "Authorization: Bearer " -``` - -Response 204: - -- No content. - -### 6.5 POST /auth/bots - -Request: - -```bash -curl -X POST "http://localhost:8000/auth/bots" \ - -H "Authorization: Bearer " \ - -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 " -``` - -### 6.7 POST /auth/bots/{bot_id}/tokens - -Request: - -```bash -curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ - -H "Authorization: Bearer " \ - -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 " -``` - -### 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 " -``` - -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 " -``` - -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 " -``` - -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 " -``` - -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 " \ - -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 " \ - -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 " -``` - -Response 204: - -- No content. - -Request: - -```bash -curl -X GET "http://localhost:8000/strategy-sets" \ - -H "Authorization: Bearer " -``` - -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 " \ - -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 " \ - -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 " -``` - -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" -} -``` diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md deleted file mode 100644 index 0f1f9ed..0000000 --- a/docs/db-schema-spec.md +++ /dev/null @@ -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 | diff --git a/docs/deployment.md b/docs/deployment.md deleted file mode 100644 index 0c5f3c0..0000000 --- a/docs/deployment.md +++ /dev/null @@ -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 -``` diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md deleted file mode 100644 index 3a1fb6a..0000000 --- a/docs/portfoliohub-spec.md +++ /dev/null @@ -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 ` 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 [--recent 5]` -- `portfoliohub portfolio create --symbol ... [--name ...] [options] [--force]` -- `portfoliohub portfolio update [options] [--force]` -- `portfoliohub portfolio buy --quantity ... --price ... [options] [--force]` -- `portfoliohub portfolio sell --quantity ... --price ... [options] [--force]` -- `portfoliohub portfolio invest --type --amount ... [options] [--force]` -- `portfoliohub portfolio transfer --direction --amount ... [options] [--force]` -- `portfoliohub portfolio reset [--infer-symbol ...] [--force]` -- `portfoliohub portfolio delete [--keep-ledger] [--force]` - -Portfolio comment subcommands: - -- `portfoliohub portfolio comment add --content ` -- `portfoliohub portfolio comment list ` -- `portfoliohub portfolio comment update --content [--force]` -- `portfoliohub portfolio comment delete [--force]` - -Strategy subcommands: - -- `portfoliohub strategy list` -- `portfoliohub strategy create --name ... --config ...` -- `portfoliohub strategy update [--name ...] [--config ...]` -- `portfoliohub strategy delete [--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 diff --git a/docs/technical/api-contracts.md b/docs/technical/api-contracts.md new file mode 100644 index 0000000..061173e --- /dev/null +++ b/docs/technical/api-contracts.md @@ -0,0 +1,663 @@ +# API Contracts + +- Related REQ: `REQ-001`, `REQ-002`, `REQ-003` + +## 공통 원칙 +- 응답은 JSON 기반으로 반환한다. +- 인증이 필요한 엔드포인트는 `Authorization: Bearer `을 사용한다. +- 권한 계층은 `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 " +``` + +```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 " +``` + +```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 " \ + -H "Content-Type: application/json" \ + -d '{ + "portfolio_id": "krw-btc", + "broker": "upbit", + "status": "enabled", + "name": "Bitcoin", + "symbol": "KRW-BTC", + "strategy_name": "target_value", + "strategy_params": {}, + "active_strategy_id": null, + "avabile_strategies": null, + "cash_balance": 1000000, + "asset_quantity": 0, + "asset_price": 0, + "profit_buffer": 0 + }' +``` + +#### `PATCH /portfolios/{portfolio_id}` +- Auth: `trade-write` +- Body: `UpdatePortfolioRequest` +- 응답 `200`: `PortfolioDetailResponse` + +```bash +curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "broker": "upbit", + "name": "Bitcoin", + "symbol": "KRW-BTC" + }' +``` + +#### 거래/자금 이동 엔드포인트 +- `POST /portfolios/{portfolio_id}/buy`: `BuyRequest` -> `PortfolioDetailResponse` +- `POST /portfolios/{portfolio_id}/sell`: `SellRequest` -> `PortfolioDetailResponse` +- `POST /portfolios/{portfolio_id}/invest`: `InvestRequest` -> `PortfolioDetailResponse` +- `POST /portfolios/{portfolio_id}/transfer`: `TransferRequest` -> `PortfolioDetailResponse` +- `POST /portfolios/{portfolio_id}/reset`: `ResetPortfolioRequest` -> `PortfolioDetailResponse` +- `DELETE /portfolios/{portfolio_id}`: admin only, query `keep_ledger: bool = false`, 응답 `204` + +대표 요청: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/buy" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' + +curl -X POST "http://localhost:8000/portfolios/krw-btc/sell" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}' + +curl -X POST "http://localhost:8000/portfolios/krw-btc/invest" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"movement_type": "deposit", "amount": 50000, "note": ""}' + +curl -X POST "http://localhost:8000/portfolios/krw-btc/transfer" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"direction": "to-buffer", "amount": 10000, "note": ""}' + +curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"infer_symbol": "KRW-BTC"}' + +curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \ + -H "Authorization: Bearer " +``` + +#### 포트폴리오 댓글 (`REQ-001`) +- `POST /portfolios/{portfolio_id}/comments` + - Auth: `trade-write` + - Body: `CommentCreateRequest` + - Response `201`: `CommentResponse` +- `GET /portfolios/{portfolio_id}/comments` + - Auth: `read` + - Response `200`: `CommentResponse[]` +- `PATCH /portfolios/{portfolio_id}/comments/{comment_id}` + - Auth: `trade-write` + - Body: `CommentUpdateRequest` + - Response `200`: `CommentResponse` +- `DELETE /portfolios/{portfolio_id}/comments/{comment_id}` + - Auth: `trade-write` + - Response `204` + +예시: + +```bash +curl -X POST "http://localhost:8000/portfolios/krw-btc/comments" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"content": "Bought more BTC today.\nLooking good."}' + +curl -X GET "http://localhost:8000/portfolios/krw-btc/comments" \ + -H "Authorization: Bearer " + +curl -X PATCH "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"content": "Updated note: BTC rally continues."}' + +curl -X DELETE "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ + -H "Authorization: Bearer " +``` + +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "portfolio_id": "krw-btc", + "content": "Bought more BTC today.\nLooking good.", + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" +} +``` + +### 전략셋 + +#### `GET /strategy-sets` +- Auth: `read` +- Response `200`: `StrategySetResponse[]` + +#### `POST /strategy-sets` +- Auth: `trade-write` +- Body: `CreateStrategySetRequest` +- Response `201`: `StrategySetResponse` +- 기존 `(name, config)` 쌍이 있으면 기존 strategy set 반환 + +#### `PATCH /strategy-sets/{strategy_set_id}` +- Auth: `trade-write` +- Body: `UpdateStrategySetRequest` +- Response `200`: `StrategySetResponse` + +#### `DELETE /strategy-sets/{strategy_set_id}` +- Auth: `trade-write` +- Response `204` +- in use 상태면 `409` + +대표 요청: + +```bash +curl -X POST "http://localhost:8000/strategy-sets" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "target_value", "config": "{}"}' + +curl -X PATCH "http://localhost:8000/strategy-sets/stv-001" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"config": "{\"rebalance\": true}"}' + +curl -X DELETE "http://localhost:8000/strategy-sets/stv-001" \ + -H "Authorization: Bearer " +``` + +### 인증 + +#### `POST /auth/login` +- Auth 없음 +- Body: `LoginRequest` +- Response `200`: `TokenResponse` +- 잘못된 username/password면 `401` + +```bash +curl -X POST "http://localhost:8000/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "bootstrap123" + }' +``` + +```json +{ + "access_token": "plain-token-value", + "token_type": "bearer", + "expires_at": "2026-04-03T12:00:00Z", + "permission": "admin", + "user": { + "id": "user-1", + "username": "admin", + "email": "admin@example.com", + "permission": "admin", + "is_active": true, + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z", + "last_login_at": "2026-04-03T00:00:00Z" + } +} +``` + +#### `GET /auth/tokens` +- Auth: DB-issued `read` 이상 토큰 +- Response `200`: `AuthTokenResponse[]` + +#### `POST /auth/tokens` +- Auth: DB-issued `read` 이상 토큰 +- Body: `CreateClientTokenRequest` +- Response `201`: `IssuedClientTokenResponse` +- 요청 permission이 현재 permission을 넘으면 `403` + +#### `DELETE /auth/tokens/{token_id}` +- Auth: DB-issued `read` 이상 토큰 +- Response `204` +- 미존재 시 `404` + +대표 요청: + +```bash +curl -X POST "http://localhost:8000/auth/tokens" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "cli", "permission": "trade-write", "expires_in_days": 30}' +``` + +#### 봇 주체와 봇 토큰 +- `POST /auth/bots`: admin only, `CreateBotPrincipalRequest` -> `BotPrincipalResponse` +- `GET /auth/bots`: admin only, `is_active` 필터 지원 +- `GET /auth/bots/{bot_id}`: admin only +- `DELETE /auth/bots/{bot_id}`: admin only, bot 비활성화 및 연결 토큰 revoke, 응답 `200` +- `POST /auth/bots/{bot_id}/tokens`: admin only, `CreateBotTokenRequest` -> `IssuedBotTokenResponse` +- `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]` +- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204` + +대표 요청: + +```bash +curl -X POST "http://localhost:8000/auth/bots" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "rebalance-bot", "permission": "trade-write", "description": "nightly jobs"}' + +curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}' +``` + +### 사용자 + +모든 `/users` 엔드포인트는 admin only이다. + +- `GET /users`: `UserResponse[]` +- `GET /users/{user_id}`: `UserResponse`, 미존재 시 `404` +- `POST /users`: `CreateUserRequest` -> `UserResponse`, duplicate username/email이면 `409` +- `PATCH /users/{user_id}`: `UpdateUserRequest` -> `UserResponse`, duplicate면 `409` +- `DELETE /users/{user_id}`: `204` + +bootstrap 규칙: + +- DB persistence가 활성이고 `PORTFOLIOHUB_BOOTSTRAP_USERNAME`, `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`가 설정되면, `users` 테이블이 비어 있을 때만 startup에서 초기 admin user를 생성한다. + +대표 요청: + +```bash +curl -X GET "http://localhost:8000/users" \ + -H "Authorization: Bearer " + +curl -X POST "http://localhost:8000/users" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice", + "email": "alice@example.com", + "password": "password123", + "permission": "read", + "is_active": true + }' +``` + +## 요청/응답 모델 요약 + +### Request Models + +`CreatePortfolioRequest` + +- `portfolio_id: str` +- `broker: str = "kis"` +- `status: str = "enabled"` +- `name: str | null` +- `symbol: str` +- `strategy_name: str = "target_value"` +- `strategy_params: object = {}` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` +- `cash_balance: float >= 0` +- `asset_quantity: float >= 0` +- `asset_price: float >= 0` +- `profit_buffer: float >= 0` + +`UpdatePortfolioRequest` + +- `broker: str | null` +- `status: str | null` +- `name: str | null` +- `symbol: str | null` +- `strategy_name: str | null` +- `strategy_params: object | null` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` +- `asset_price: float >= 0 | null` + +`BuyRequest` / `SellRequest` + +- `quantity: float > 0` + +`InvestRequest` + +- `movement_type: "deposit" | "profit" | "withdraw"` +- `amount: float > 0` +- `note: str = ""` + +`TransferRequest` + +- `direction: "to-buffer" | "from-buffer"` +- `amount: float > 0` +- `note: str = ""` + +`ResetPortfolioRequest` + +- `infer_symbol: str | null` + +`CommentCreateRequest` / `CommentUpdateRequest` + +- `content: str` + +`LoginRequest` + +- `username: str` +- `password: str` + +`CreateClientTokenRequest` + +- `name: str` +- `permission: "read" | "trade-write" | "admin" | null` +- `expires_in_days: int | null` + +`CreateBotPrincipalRequest` + +- `name: str` +- `permission: "read" | "trade-write" | "admin"` +- `description: str | null` + +`CreateBotTokenRequest` + +- `name: str` +- `permission: "read" | "trade-write" | "admin" | null` +- `expires_in_days: int | null` + +`CreateUserRequest` + +- `username: str` +- `email: str | null` +- `password: str` +- `permission: "read" | "trade-write" | "admin"` +- `is_active: bool` + +`UpdateUserRequest` + +- `username: str | null` +- `email: str | null` +- `password: str | null` +- `permission: "read" | "trade-write" | "admin" | null` +- `is_active: bool | null` + +`CreateStrategySetRequest` + +- `name: str` +- `config: str` + +`UpdateStrategySetRequest` + +- `name: str | null` +- `config: str | null` + +### Response Models + +`PortfolioListItem` + +- portfolio summary fields + computed `total_value` + +`PortfolioSummary` + +- `portfolio_id: str` +- `broker: str` +- `status: str` +- `name: str` +- `symbol: str` +- `cash_balance: float` +- `asset_quantity: float` +- `price: float` +- `profit_buffer: float` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` +- `created_at: datetime | null` +- `updated_at: datetime | null` +- `last_trade_at: datetime | null` + +`PortfolioDetailResponse` + +- `portfolio: PortfolioSummary` +- `recent_entries: LedgerEntryResponse[]` +- `strategy_set: StrategySetResponse | null` +- `live_quantity: float | null` + +`StrategySetResponse` + +- `id: str` +- `name: str` +- `config: str` +- `nick: str` +- `created_at: datetime | null` + +`CommentResponse` + +- `id: str` +- `portfolio_id: str` +- `content: str` +- `created_at: datetime | null` +- `updated_at: datetime | null` + +`BotPrincipalResponse` + +- `id: str` +- `name: str` +- `permission: "read" | "trade-write" | "admin"` +- `description: str | null` +- `is_active: bool` +- `created_at: datetime | null` +- `updated_at: datetime | null` +- `deactivated_at: datetime | null` + +`ErrorResponse` + +- `detail: str` + +## 공통 오류 예시 + +### Missing token + +```json +{"detail": "Not authenticated"} +``` + +### Invalid token + +```json +{"detail": "Invalid token"} +``` + +### Insufficient permission + +```json +{"detail": "Insufficient permission"} +``` + +## 문서 동기화 규칙 +- `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다. +- `REQ-002` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다. + +## Open Implementation Points +- 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다. +- idempotency가 필요한 쓰기 API(예: 재시도 가능 매매 요청)의 키 정책을 확정한다. diff --git a/docs/technical/architecture-overview.md b/docs/technical/architecture-overview.md new file mode 100644 index 0000000..699c83a --- /dev/null +++ b/docs/technical/architecture-overview.md @@ -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` 태그 검색 구현 시 조회 성능 요구를 만족하는 인덱스/쿼리 전략을 확정한다. +- 관측성(메트릭/로그)의 표준 이벤트 스키마를 별도 문서로 확장할지 결정한다. diff --git a/docs/technical/auth-and-token-lifecycle.md b/docs/technical/auth-and-token-lifecycle.md new file mode 100644 index 0000000..7893a28 --- /dev/null +++ b/docs/technical/auth-and-token-lifecycle.md @@ -0,0 +1,54 @@ +# Auth and Token Lifecycle + +- Related REQ: N/A + +## 목표 +정적 토큰과 DB 발급 토큰을 병행하는 현재 인증 모델의 동작 기준을 명확히 한다. + +## 권한 모델 + +| Permission | 허용 범위 | +| --- | --- | +| `read` | 조회 계열 API | +| `trade-write` | 조회 + 포트폴리오 변경 API | +| `admin` | 전체 API + 사용자/봇 관리 | + +## 인증 방식 + +- 보호된 API는 `Authorization: Bearer ` 헤더를 사용한다. +- 인증 소스는 정적 토큰과 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)에 토큰 발급/폐기 이벤트를 표준 포맷으로 남길지 확정한다. diff --git a/docs/technical/deployment-and-operations.md b/docs/technical/deployment-and-operations.md new file mode 100644 index 0000000..e7b9a55 --- /dev/null +++ b/docs/technical/deployment-and-operations.md @@ -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 통합 운영 절차를 별도 런북으로 분리할지 결정한다. diff --git a/docs/technical/index.md b/docs/technical/index.md new file mode 100644 index 0000000..8a36f82 --- /dev/null +++ b/docs/technical/index.md @@ -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 업데이트 여부를 별도로 판정한다. diff --git a/docs/technical/management-cli-operations.md b/docs/technical/management-cli-operations.md new file mode 100644 index 0000000..d30e425 --- /dev/null +++ b/docs/technical/management-cli-operations.md @@ -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 [--recent 5]` +- `portfoliohub portfolio create --symbol ... [--name ...] [options] [--force]` +- `portfoliohub portfolio update [options] [--force]` +- `portfoliohub portfolio buy --quantity ... --price ... [options] [--force]` +- `portfoliohub portfolio sell --quantity ... --price ... [options] [--force]` +- `portfoliohub portfolio invest --type --amount ... [options] [--force]` +- `portfoliohub portfolio transfer --direction --amount ... [options] [--force]` +- `portfoliohub portfolio reset [--infer-symbol ...] [--force]` +- `portfoliohub portfolio delete [--keep-ledger] [--force]` + +포트폴리오 댓글 하위 명령: + +- `portfoliohub portfolio comment add --content ` +- `portfoliohub portfolio comment list ` +- `portfoliohub portfolio comment update --content [--force]` +- `portfoliohub portfolio comment delete [--force]` + +전략셋 하위 명령: + +- `portfoliohub strategy list` +- `portfoliohub strategy create --name ... --config ...` +- `portfoliohub strategy update [--name ...] [--config ...]` +- `portfoliohub strategy delete [--force]` + +## 실행 원칙 +- 쓰기 명령은 기본적으로 확인 프롬프트를 띄운다. +- 자동화 스크립트에서는 `--force`를 사용해 비대화식 실행을 보장한다. +- DB 타깃 실행 시 `PORTFOLIOHUB_DB_ENABLED=true`와 `TIMESCALEDB_*`를 명시한다. + +## 예시 시나리오 +1. 신규 환경 준비 + - `portfoliohub init-db` + - `portfoliohub health-check` +2. 일상 운영 + - `portfoliohub portfolio list` + - `portfoliohub portfolio status ` +3. 배포 후 검증 + - `portfoliohub user list` + - 주요 API smoke test와 결과 비교 + +## 오류/종료코드 가이드 +- `0`: 정상 종료 +- `1`: 입력/검증/런타임 오류 +- 재시도 가능 오류(예: 일시적 DB 연결 실패)는 메시지에 원인을 명확히 표기한다. + +확인 동작: + +- 변경 명령은 기본적으로 확인 프롬프트를 요청한다. +- `--force`를 지정하면 확인 프롬프트를 생략한다. + +## Open Implementation Points +- JSON 출력 모드(`--json`)를 도입해 자동화 친화성을 높일지 결정한다. +- 대량 작업(batch) 명령 추가 시 실패 항목 보고 포맷을 표준화한다. diff --git a/docs/technical/persistence-and-migrations.md b/docs/technical/persistence-and-migrations.md new file mode 100644 index 0000000..9eec26a --- /dev/null +++ b/docs/technical/persistence-and-migrations.md @@ -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 등)과 마이그레이션 단계를 추가 정의한다. diff --git a/docs/technical/project-structure-baseline.md b/docs/technical/project-structure-baseline.md new file mode 100644 index 0000000..8b4ce6d --- /dev/null +++ b/docs/technical/project-structure-baseline.md @@ -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`) 구현 시 서비스/리포지토리 경계의 책임 분리를 추가 명세한다. diff --git a/docs/technical/python-runtime-and-packaging.md b/docs/technical/python-runtime-and-packaging.md new file mode 100644 index 0000000..5c04da1 --- /dev/null +++ b/docs/technical/python-runtime-and-packaging.md @@ -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 정책을 문서화한다. diff --git a/docs/technical/settings-and-env-loading.md b/docs/technical/settings-and-env-loading.md new file mode 100644 index 0000000..6485800 --- /dev/null +++ b/docs/technical/settings-and-env-loading.md @@ -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 활성화 플래그와 실패 시 동작(무시/실패)을 명시적으로 분리할지 결정한다. diff --git a/scripts/check_docs_sync.py b/scripts/check_docs_sync.py index 7e315db..959f2a2 100644 --- a/scripts/check_docs_sync.py +++ b/scripts/check_docs_sync.py @@ -2,11 +2,11 @@ """Guardrail check for implementation-to-spec synchronization. Rules: -- If files under app/ change, docs/portfoliohub-spec.md must be part of the same change set. -- If files under app/api/ or app/schemas/ change, docs/api-examples.md must be part of the same change set. -- If files under migrations/versions/ change, docs/db-schema-spec.md must be part of the same change set. +- If files under app/ change, at least one docs/technical/ file must be part of the same change set. +- If files under app/api/ or app/schemas/ change, docs/technical/api-contracts.md must be part of the same change set. +- If files under migrations/versions/ change, docs/technical/persistence-and-migrations.md must be part of the same change set. - If migrations/versions/ files change, revision IDs found in changed migration files - must appear in docs/db-schema-spec.md. + must appear in docs/technical/persistence-and-migrations.md. """ from __future__ import annotations @@ -18,9 +18,9 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -GENERAL_SPEC = "docs/portfoliohub-spec.md" -DB_SPEC = "docs/db-schema-spec.md" -API_EXAMPLES_SPEC = "docs/api-examples.md" +TECHNICAL_DOC_PREFIX = "docs/technical/" +DB_SPEC = "docs/technical/persistence-and-migrations.md" +API_EXAMPLES_SPEC = "docs/technical/api-contracts.md" APP_PREFIX = "app/" API_PREFIXES = ("app/api/", "app/schemas/") MIGRATION_PREFIX = "migrations/versions/" @@ -110,32 +110,32 @@ def run_check(*, staged: bool, against: str | None, head: str) -> int: errors: list[str] = [] - if needs_general_spec and GENERAL_SPEC not in changed_files: + if needs_general_spec and not any(path.startswith(TECHNICAL_DOC_PREFIX) for path in changed_files): errors.append( - "Application code changed but docs/portfoliohub-spec.md was not updated in the same commit/PR." + "Application code changed but no docs/technical/ document was updated in the same commit/PR." ) if needs_api_examples and API_EXAMPLES_SPEC not in changed_files: errors.append( - "API/schema code changed but docs/api-examples.md was not updated in the same commit/PR." + "API/schema code changed but docs/technical/api-contracts.md was not updated in the same commit/PR." ) if needs_db_spec and DB_SPEC not in changed_files: errors.append( - "Migration changed but docs/db-schema-spec.md was not updated in the same commit/PR." + "Migration changed but docs/technical/persistence-and-migrations.md was not updated in the same commit/PR." ) if needs_db_spec: db_spec_path = ROOT / DB_SPEC if not db_spec_path.exists(): - errors.append("docs/db-schema-spec.md is missing.") + errors.append("docs/technical/persistence-and-migrations.md is missing.") else: db_spec_text = db_spec_path.read_text(encoding="utf-8") changed_revisions = _collect_changed_revision_ids(changed_files) missing_revisions = [rev for rev in changed_revisions if rev not in db_spec_text] if missing_revisions: errors.append( - "Migration revision IDs missing in docs/db-schema-spec.md: " + "Migration revision IDs missing in docs/technical/persistence-and-migrations.md: " + ", ".join(sorted(set(missing_revisions))) )