642 lines
15 KiB
Markdown
642 lines
15 KiB
Markdown
# PortfolioHub Specification
|
|
|
|
## 1. Overview
|
|
|
|
PortfolioHub is a FastAPI-based portfolio lifecycle service with a Typer CLI.
|
|
|
|
- Runtime: Python 3.11+
|
|
- API framework: FastAPI
|
|
- CLI framework: Typer
|
|
- DB driver: Psycopg v3
|
|
- Migration: Alembic
|
|
|
|
Primary goals:
|
|
|
|
- Manage portfolio lifecycle (create/update/delete/reset)
|
|
- Record trade and balance-affecting actions (buy/sell/invest/transfer)
|
|
- Expose status and ledger snapshots through API and CLI
|
|
- Keep API and CLI behavior aligned through a shared service/repository layer
|
|
|
|
## 2. Scope and Non-Goals
|
|
|
|
In scope:
|
|
|
|
- Authenticated portfolio read and write operations
|
|
- Admin-managed user CRUD operations
|
|
- Role-based access control (read, trade-write, admin)
|
|
- Optional PostgreSQL/Timescale-backed repository via feature flag
|
|
- Operational commands for DB bootstrap and migration
|
|
|
|
Current non-goals / limitations:
|
|
|
|
- In-memory fallback repository is not implemented for production usage
|
|
- External broker execution is not handled here (service focuses on portfolio state)
|
|
- Advanced observability wiring for Redis/Loki is not yet implemented in runtime paths
|
|
|
|
## 3. Architecture
|
|
|
|
Layered components:
|
|
|
|
- API layer: HTTP routes and request validation
|
|
- CLI layer: command parsing and output rendering
|
|
- Service layer: orchestration and response shaping
|
|
- Domain layer: business logic rules
|
|
- Repository layer: persistence abstraction and concrete Postgres implementation
|
|
|
|
Key design principle:
|
|
|
|
- API and CLI call the same `PortfolioService`, so behavior is consistent regardless of entrypoint.
|
|
|
|
## 4. Entrypoints
|
|
|
|
HTTP service:
|
|
|
|
- App factory: `app.main:create_app`
|
|
- ASGI app: `app.main:app`
|
|
- OpenAPI docs: `/docs`
|
|
- ReDoc: `/redoc`
|
|
|
|
CLI:
|
|
|
|
- Console script: `portfoliohub`
|
|
- Entry function: `app.cli.main:main`
|
|
|
|
## 5. Configuration Specification
|
|
|
|
Configuration precedence (high to low):
|
|
|
|
1. Environment variables
|
|
2. `.env` file (loaded at startup if present)
|
|
3. Code defaults
|
|
|
|
Core settings:
|
|
|
|
- `PORTFOLIOHUB_ALLOWED_ORIGINS`: comma-separated CORS origins
|
|
- `PORTFOLIOHUB_BOOTSTRAP_USERNAME`: initial username created on startup when users table is empty
|
|
- `PORTFOLIOHUB_BOOTSTRAP_EMAIL`: optional email for the bootstrap user
|
|
- `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`: initial password for the bootstrap admin user
|
|
- `PORTFOLIOHUB_READ_TOKENS`: comma-separated read tokens
|
|
- `PORTFOLIOHUB_TRADE_WRITE_TOKENS`: comma-separated write tokens
|
|
- `PORTFOLIOHUB_ADMIN_TOKENS`: comma-separated admin tokens
|
|
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true`
|
|
- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL in hours
|
|
- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: default client token TTL in days
|
|
- `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS`: default bot token TTL in days
|
|
|
|
Database settings (active source):
|
|
|
|
- `TIMESCALEDB_HOST`
|
|
- `TIMESCALEDB_PORT`
|
|
- `TIMESCALEDB_NAME`
|
|
- `TIMESCALEDB_USER`
|
|
- `TIMESCALEDB_PASSWORD`
|
|
- `TIMESCALEDB_SSLMODE`
|
|
- `TIMESCALEDB_CONNECT_TIMEOUT_SEC`
|
|
- `TIMESCALEDB_APPLICATION_NAME`
|
|
|
|
Derived setting:
|
|
|
|
- `active_database_url` uses PostgreSQL psycopg URL format with encoded credentials and query options.
|
|
|
|
## 6. Authentication and Authorization
|
|
|
|
Auth scheme:
|
|
|
|
- `Authorization: Bearer <token>` required for protected API routes
|
|
|
|
Permission levels:
|
|
|
|
- `read`
|
|
- `trade-write`
|
|
- `admin`
|
|
|
|
Authorization hierarchy:
|
|
|
|
- `admin` can access all endpoints
|
|
- `trade-write` can access `trade-write` + `read`
|
|
- `read` can access only read endpoints
|
|
|
|
Failure responses:
|
|
|
|
- Missing/invalid bearer header: `401`
|
|
- Unknown token: `401`
|
|
- Insufficient permission: `403`
|
|
|
|
## 7. HTTP API Specification
|
|
|
|
Base routes:
|
|
|
|
- Health: `/health`
|
|
- Auth: `/auth`
|
|
- Portfolio: `/portfolios`
|
|
- Users: `/users`
|
|
|
|
### 7.1 Health Endpoint
|
|
|
|
- `GET /health`
|
|
- Auth: none
|
|
- Response `200`:
|
|
|
|
```json
|
|
{"status": "ok"}
|
|
```
|
|
|
|
### 7.2 Portfolio Endpoints
|
|
|
|
#### List Portfolios
|
|
|
|
- `GET /portfolios`
|
|
- Auth: `read`
|
|
- Query params:
|
|
- `broker` (optional string)
|
|
- `status_filter` (optional string)
|
|
- Response `200`: array of `PortfolioListItem`
|
|
|
|
#### Get Portfolio Status
|
|
|
|
- `GET /portfolios/{portfolio_id}`
|
|
- Auth: `read`
|
|
- Query params:
|
|
- `recent_trades` (int, default `5`)
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Create Portfolio
|
|
|
|
- `POST /portfolios`
|
|
- Auth: `trade-write`
|
|
- Body: `CreatePortfolioRequest`
|
|
- Response `201`: `PortfolioDetailResponse`
|
|
|
|
#### Update Portfolio
|
|
|
|
- `PATCH /portfolios/{portfolio_id}`
|
|
- Auth: `trade-write`
|
|
- Body: `UpdatePortfolioRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Buy
|
|
|
|
- `POST /portfolios/{portfolio_id}/buy`
|
|
- Auth: `trade-write`
|
|
- Body: `BuyRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Sell
|
|
|
|
- `POST /portfolios/{portfolio_id}/sell`
|
|
- Auth: `trade-write`
|
|
- Body: `SellRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Invest
|
|
|
|
- `POST /portfolios/{portfolio_id}/invest`
|
|
- Auth: `trade-write`
|
|
- Body: `InvestRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Transfer
|
|
|
|
- `POST /portfolios/{portfolio_id}/transfer`
|
|
- Auth: `trade-write`
|
|
- Body: `TransferRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Reset
|
|
|
|
- `POST /portfolios/{portfolio_id}/reset`
|
|
- Auth: `trade-write`
|
|
- Body: `ResetPortfolioRequest`
|
|
- Response `200`: `PortfolioDetailResponse`
|
|
|
|
#### Delete
|
|
|
|
- `DELETE /portfolios/{portfolio_id}`
|
|
- Auth: `admin`
|
|
- Query params:
|
|
- `keep_ledger` (bool, default `false`)
|
|
- Response `204`: no content
|
|
|
|
### 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"`
|
|
- `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`
|
|
- `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`
|
|
|
|
- `owner_user_id: str`
|
|
- `name: str`
|
|
- `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`
|
|
|
|
`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`
|
|
- `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`
|
|
|
|
`BotPrincipalResponse`
|
|
|
|
- `id: str`
|
|
- `owner_user_id: str`
|
|
- `name: str`
|
|
- `description: str | null`
|
|
- `is_active: bool`
|
|
- `created_at: datetime | null`
|
|
- `updated_at: datetime | null`
|
|
- `deactivated_at: datetime | null`
|
|
|
|
## 9. CLI Specification
|
|
|
|
Global option:
|
|
|
|
- `--format [table|json]` (default: `table`)
|
|
|
|
Commands:
|
|
|
|
- `portfoliohub init-db [--maintenance-db postgres]`
|
|
- `portfoliohub migrate-db`
|
|
- `portfoliohub health-check`
|
|
|
|
Portfolio subcommands:
|
|
|
|
- `portfoliohub portfolio list [--broker ...] [--status ...]`
|
|
- `portfoliohub portfolio status <portfolio_id> [--recent 5]`
|
|
- `portfoliohub portfolio create <portfolio_id> --symbol ... [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]`
|
|
|
|
Strategy subcommands:
|
|
|
|
- `portfoliohub strategy list`
|
|
- `portfoliohub strategy create --name ... --config ...`
|
|
- `portfoliohub strategy update <strategy_set_id> [--name ...] [--config ...]`
|
|
- `portfoliohub strategy delete <strategy_set_id> [--force]`
|
|
|
|
Confirmation behavior:
|
|
|
|
- Mutating portfolio commands request confirmation by default.
|
|
- `--force` bypasses confirmation prompts.
|
|
|
|
## 10. Persistence and Migration Specification
|
|
|
|
Repository selection:
|
|
|
|
- If `PORTFOLIOHUB_DB_ENABLED=false`: an unimplemented placeholder repository raises `FeatureNotReadyError` on access.
|
|
- If `PORTFOLIOHUB_DB_ENABLED=true`: `PostgresPortfolioRepository` is used.
|
|
|
|
Migration commands:
|
|
|
|
- `init-db`: create target DB if missing, then apply Alembic `head`
|
|
- `migrate-db`: apply Alembic `head` to existing target DB
|
|
|
|
Migration source of truth:
|
|
|
|
- `migrations/versions`
|
|
|
|
DB schema specification:
|
|
|
|
- Detailed table/column/index constraints are documented in `docs/db-schema-spec.md`.
|
|
- The documentation is generated from and must stay consistent with Alembic revisions under `migrations/versions`.
|
|
|
|
## 11. Error Handling
|
|
|
|
Application-level errors are translated through registered exception handlers.
|
|
|
|
Typical error classes include:
|
|
|
|
- authorization failures (`401`, `403`)
|
|
- domain/repository-level failures surfaced by service operations
|
|
- feature-not-ready errors when DB mode is disabled but persistence is required
|
|
|
|
## 12. CORS Policy
|
|
|
|
CORS middleware is enabled with:
|
|
|
|
- `allow_origins` from configured origins list
|
|
- `allow_credentials = true`
|
|
- `allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]`
|
|
- `allow_headers = ["Authorization", "Content-Type"]`
|
|
|
|
## 13. Testing Specification
|
|
|
|
Test framework:
|
|
|
|
- `pytest`
|
|
|
|
Configured behavior:
|
|
|
|
- test paths: `tests`
|
|
- recursion excluded: `temp`, `.git`, `.venv`
|
|
|
|
Current test modules include API, auth, CLI, and domain service coverage.
|
|
|
|
## 14. Versioning and Compatibility
|
|
|
|
- Current package version: `0.1.0`
|
|
- API version exposure follows application version setting (`app_version`)
|
|
- Minimum Python version: `3.11`
|
|
|
|
## 15. Future Extension Points
|
|
|
|
- Redis-backed cache/session integration
|
|
- Loki logging pipeline integration
|
|
- Additional brokers and order-execution adapters
|
|
- Enhanced operational/admin endpoints
|