# Persistence and Migrations - Related REQ: `REQ-001`, `REQ-002`, `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 한다. ## REQ-002 Tagging Storage Design 목표: - 포트폴리오별 태그 연결을 중복 없이 저장한다. - 앞뒤 공백 제거 기반 정규화와 검색용 include/exclude 조건을 DB 계층에서 일관되게 지원한다. 구현 저장 모델: - 신규 테이블 `portfolio_tags` - 복합 PK(`portfolio_id`, `tag_value`) 사용 - `portfolio_id`: 대상 포트폴리오 - `tag_value`: 앞뒤 공백 제거 후 저장되는 태그 값 - `created_at`: 연결 생성 시각 - 동일 포트폴리오 내 중복 태그는 PK 자체로 방지한다. 정규화 책임: - API/CLI 입력 계층에서 앞뒤 공백 제거를 수행한다. - repository는 이미 정규화된 `tag_value`를 기준으로 insert/delete/query를 수행한다. - DB는 `UNIQUE (portfolio_id, tag_value)`로 중복 insert를 방지하고 application은 이를 `409 Conflict`로 매핑한다. 검색 전략: - `includeTags`와 `excludeTags`는 모두 정규화된 태그 값 기준으로 비교한다. - `matchMode=AND`는 `GROUP BY portfolio_id` + `HAVING COUNT(DISTINCT tag_value) = ` 전략을 기본안으로 둔다. - `matchMode=OR`는 `WHERE tag_value IN (...)` + distinct portfolio projection을 기본안으로 둔다. - `excludeTags`는 anti-join 또는 `NOT EXISTS` 전략을 사용한다. 권장 인덱스: - `UNIQUE (portfolio_id, tag_value)` - `INDEX (tag_value, portfolio_id)` - owner-scoped 목록 필터와 결합될 수 있으므로 `portfolios.owner_user_id` 인덱스와 함께 실행 계획을 검토한다. 적용 마이그레이션 단계: 1. `20260414_0012_portfolio_tags.py`에서 `portfolio_tags` 테이블 생성 2. `fk_portfolio_tags_portfolio` FK와 `idx_portfolio_tags_tag_portfolio` 인덱스 생성 3. API/CLI/repository가 동일 revision 기준으로 태그 mutation 및 검색을 사용 4. 필요 시 exact-match 중심 추가 인덱스를 검토 ## 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 | | 20260414_0011 | migrations/versions/20260414_0011_bot_principal_owner_user_id.py | Restore `bot_principals.owner_user_id`, backfill owner, create owner index, and add owner FK | | 20260414_0012 | migrations/versions/20260414_0012_portfolio_tags.py | Add `portfolio_tags`, portfolio FK, and tag lookup index for REQ-002 | ## Open Implementation Points - `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다. - owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다.