# 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 등)과 마이그레이션 단계를 추가 정의한다.