# 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 ### 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` `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` `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]` 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