Files
portfolio-hub/docs/technical/persistence-and-migrations.md
T

283 lines
12 KiB
Markdown

# Persistence and Migrations
- Related REQ: `REQ-001`, `REQ-003`, `REQ-004`
## 목표
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: `20260413_0010`
- 상세 테이블/컬럼/인덱스 제약은 이 문서가 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 `20260413_0010`까지의 마이그레이션 기준이다.
| 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 |
| owner_user_id | Text | NO | - | FK to `users.id` |
| 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: `fk_portfolios_owner_user` on (`owner_user_id`) -> `users(id)`
#### 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_portfolios_owner_updated` | `portfolios` | `owner_user_id`, `updated_at DESC` | no | owner-scoped 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은 필수 ownership 제약(`portfolios.owner_user_id` -> `users.id`)만 DB-level FK를 사용한다.
- ownership 외 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 |
| 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK |
## Open Implementation Points
- `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다.
- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다.