chore: add docs sync guardrails and spec documents

This commit is contained in:
2026-04-03 07:53:20 +00:00
parent 3e3acbd13d
commit 3eae90a9ab
8 changed files with 1063 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
# PortfolioHub API Examples
## 1. Purpose
This document contains example requests and responses for PortfolioHub HTTP APIs.
- Sync rule: when files under `app/api/` or `app/schemas/` change, this document must be updated in the same commit/PR.
- Companion spec: `docs/portfoliohub-spec.md`
## 2. Conventions
- Base URL example: `http://localhost:8000`
- Auth header format: `Authorization: Bearer <TOKEN>`
- JSON examples are representative and may omit some optional fields.
## 3. Health
### 3.1 GET /health
Request:
```bash
curl -X GET "http://localhost:8000/health"
```
Response 200:
```json
{
"status": "ok"
}
```
## 4. Portfolios
### 4.1 GET /portfolios
Request:
```bash
curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```json
[
{
"portfolio_id": "krw-btc",
"broker": "upbit",
"status": "enabled",
"symbol": "KRW-BTC",
"strategy_name": "target_value",
"strategy_params": {},
"cash_balance": 1000000.0,
"asset_quantity": 0.1,
"price": 100000000.0,
"profit_buffer": 0.0,
"current_strategy_set_id": "stv-001",
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z",
"last_trade_at": "2026-04-03T00:00:00Z",
"total_value": 11000000.0
}
]
```
### 4.2 GET /portfolios/{portfolio_id}
Request:
```bash
curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \
-H "Authorization: Bearer <READ_TOKEN>"
```
Response 200:
```json
{
"portfolio": {
"portfolio_id": "krw-btc",
"broker": "upbit",
"status": "enabled",
"symbol": "KRW-BTC",
"strategy_name": "target_value",
"strategy_params": {},
"cash_balance": 1000000.0,
"asset_quantity": 0.1,
"price": 100000000.0,
"profit_buffer": 0.0,
"current_strategy_set_id": "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",
"strategy_name": "target_value",
"strategy_config": "{}",
"created_at": "2026-04-03T00:00:00Z"
},
"live_quantity": null
}
```
### 4.3 POST /portfolios
Request:
```bash
curl -X POST "http://localhost:8000/portfolios" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"portfolio_id": "krw-btc",
"broker": "upbit",
"status": "enabled",
"symbol": "KRW-BTC",
"strategy_name": "target_value",
"strategy_params": {},
"cash_balance": 1000000,
"asset_quantity": 0,
"asset_price": 0,
"profit_buffer": 0
}'
```
Response 201:
- Response body shape is `PortfolioDetailResponse` (same as 4.2).
### 4.4 PATCH /portfolios/{portfolio_id}
Request:
```bash
curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"broker": "upbit",
"symbol": "KRW-BTC"
}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.5 POST /portfolios/{portfolio_id}/buy
Request:
```bash
curl -X POST "http://localhost:8000/portfolios/krw-btc/buy" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.6 POST /portfolios/{portfolio_id}/sell
Request:
```bash
curl -X POST "http://localhost:8000/portfolios/krw-btc/sell" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"quantity": 0.01, "price": 100000000, "fee": 0, "tax": 0, "note": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.7 POST /portfolios/{portfolio_id}/invest
Request:
```bash
curl -X POST "http://localhost:8000/portfolios/krw-btc/invest" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"movement_type": "deposit", "amount": 50000, "note": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.8 POST /portfolios/{portfolio_id}/transfer
Request:
```bash
curl -X POST "http://localhost:8000/portfolios/krw-btc/transfer" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"direction": "to-buffer", "amount": 10000, "note": ""}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.9 POST /portfolios/{portfolio_id}/reset
Request:
```bash
curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"infer_symbol": "KRW-BTC"}'
```
Response 200:
- Response body shape is `PortfolioDetailResponse`.
### 4.10 DELETE /portfolios/{portfolio_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 204:
- No content.
## 5. Common Error Examples
### 5.1 Missing token
Response 401:
```json
{
"detail": "Missing bearer token"
}
```
### 5.2 Invalid token
Response 401:
```json
{
"detail": "Invalid token"
}
```
### 5.3 Insufficient permission
Response 403:
```json
{
"detail": "Insufficient permission"
}
```
+157
View File
@@ -0,0 +1,157 @@
# 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: `20260403_0001`
- Baseline file: `migrations/versions/20260403_0001_initial_portfolio_schema.py`
## 2. Scope
Schema objects covered by baseline revision:
- `strategy_sets`
- `portfolios`
- `portfolio_ledger`
- 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 strategy_sets
Purpose:
- Stores normalized strategy configuration snapshots.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| strategy_name | Text | NO | - | Part of unique pair |
| strategy_config | Text | NO | "" | Part of unique pair |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
Constraints:
- Primary key: `id`
- Unique: `uq_strategy_sets_name_config` on (`strategy_name`, `strategy_config`)
### 4.2 portfolios
Purpose:
- Stores current portfolio state and strategy linkage.
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 |
| symbol | Text | NO | - | Trading symbol |
| strategy_name | Text | NO | "" | Strategy label |
| strategy_params | JSONB | NO | '{}'::jsonb | Strategy parameters |
| 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 |
| current_strategy_set_id | Text | YES | - | Logical reference to `strategy_sets.id` (no FK) |
| 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
### 4.3 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
## 5. Indexes
### 5.1 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.2 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.
## 6. TimescaleDB Behavior
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.
## 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 |
+380
View File
@@ -0,0 +1,380 @@
# 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
- 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_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`
Database settings (active source):
- `TIMESCALEDB_HOST`
- `TIMESCALEDB_PORT`
- `TIMESCALEDB_NAME`
- `TIMESCALEDB_USER`
- `TIMESCALEDB_PASSWORD`
- `TIMESCALEDB_SSLMODE`
- `TIMESCALEDB_CONNECT_TIMEOUT_SEC`
- `TIMESCALEDB_APPLICATION_NAME`
Derived setting:
- `active_database_url` uses PostgreSQL psycopg URL format with encoded credentials and query options.
## 6. Authentication and Authorization
Auth scheme:
- `Authorization: Bearer <token>` required for protected API routes
Permission levels:
- `read`
- `trade-write`
- `admin`
Authorization hierarchy:
- `admin` can access all endpoints
- `trade-write` can access `trade-write` + `read`
- `read` can access only read endpoints
Failure responses:
- Missing/invalid bearer header: `401`
- Unknown token: `401`
- Insufficient permission: `403`
## 7. HTTP API Specification
Base routes:
- Health: `/health`
- Portfolio: `/portfolios`
### 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
### 7.3 Example Requests and Responses
- See `docs/api-examples.md` for maintained request/response examples.
## 8. Schema Specification
### 8.1 Request Models
`CreatePortfolioRequest`
- `portfolio_id: str`
- `broker: str = "kis"`
- `status: str = "enabled"`
- `symbol: str`
- `strategy_name: str = "target_value"`
- `strategy_params: object = {}`
- `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`
- `symbol: str | null`
- `strategy_name: str | null`
- `strategy_params: object | null`
- `asset_price: float >= 0 | null`
`BuyRequest` / `SellRequest`
- `quantity: float > 0`
- `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`
### 8.2 Response Models
`PortfolioListItem`
- Includes portfolio summary fields and computed `total_value`
`PortfolioDetailResponse`
- `portfolio: PortfolioSummary`
- `recent_entries: LedgerEntryResponse[]`
- `strategy_set: StrategySetResponse | null`
- `live_quantity: float | null`
`LedgerEntryResponse`
- Snapshot-oriented ledger response including balances after each action
`ErrorResponse`
- `detail: str`
## 9. CLI Specification
Global option:
- `--format [table|json]` (default: `table`)
Commands:
- `portfoliohub init-db [--maintenance-db postgres]`
- `portfoliohub migrate-db`
- `portfoliohub health-check`
Portfolio subcommands:
- `portfoliohub portfolio list [--broker ...] [--status ...]`
- `portfoliohub portfolio status <portfolio_id> [--recent 5]`
- `portfoliohub portfolio create <portfolio_id> --symbol ... [options] [--force]`
- `portfoliohub portfolio update <portfolio_id> [options] [--force]`
- `portfoliohub portfolio buy <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio sell <portfolio_id> --quantity ... --price ... [options] [--force]`
- `portfoliohub portfolio invest <portfolio_id> --type <deposit|profit|withdraw> --amount ... [options] [--force]`
- `portfoliohub portfolio transfer <portfolio_id> --direction <to-buffer|from-buffer> --amount ... [options] [--force]`
- `portfoliohub portfolio reset <portfolio_id> [--infer-symbol ...] [--force]`
- `portfoliohub portfolio delete <portfolio_id> [--keep-ledger] [--force]`
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