// ============================================================================= // QuantEngine Database Schema (DBML) // DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 // (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화) // // 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성 // (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외) // ============================================================================= Project quantengine { database_type: 'PostgreSQL' Note: ''' QuantEngine v0.1 데이터베이스 스키마. 세 개 스키마로 구성: - quantengine: 핵심 KIS API 토큰, 사용자 계정, 수집 파이프라인 데이터 - engine_history: 팩터 계산 이력, 시장 데이터 이력, 의사결정 이력 - (생략) hangfire: Hangfire 백그라운드 잡 관리 (auto-created) ''' } // ============================================================================= // Schema: quantengine (V1 + V2) // ============================================================================= TableGroup "quantengine" { kis_tokens workspace_account workspace_session collection_runs collection_snapshots collection_source_errors settings account_snapshot workspace_meta workspace_change_log workspace_approval_v2 workspace_lock kis_collection_runs kis_collection_snapshots kis_collection_errors } Table quantengine.kis_tokens { account TEXT [pk, note: "KIS 계정 모드 (real/mock)"] access_token TEXT [not null, note: "KIS 토큰"] expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"] updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"] Note: "KIS Open API 인증 토큰 캐시" } Table quantengine.workspace_account { ordinal INT [not null, note: "순서 인덱스"] username TEXT [pk, note: "로그인 ID"] password_hash TEXT [not null, note: "BCrypt 또는 SHA-256 해시 (자동 마이그레이션 가능)"] role TEXT [not null, default: "'Admin'", note: "역할 (Admin)"] is_active TEXT [not null, default: "'true'", note: "활성 상태 (true/false)"] created_at TEXT [not null, note: "생성 시각 (ISO 8601)"] updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] indexes { (is_active, username) [name: "idx_workspace_account_active"] } Note: "Admin UI 사용자 계정" } Table quantengine.workspace_session { session_token_hash TEXT [pk, note: "세션 토큰 해시"] username TEXT [not null, note: "사용자명"] role TEXT [not null, default: "'Admin'", note: "역할"] created_at TEXT [not null, note: "세션 생성 시각 (ISO 8601)"] expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"] revoked_at TEXT [note: "취소 시각 (ISO 8601), NULL이면 활성"] indexes { (username, expires_at) [name: "idx_workspace_session_username"] } Note: "세션 관리 (쿠키 기반 인증)" } Table quantengine.collection_runs { run_id TEXT [pk, note: "수집 실행 ID (예: api-20260712-120000)"] collector_name TEXT [not null, note: "수집기 이름"] started_at TEXT [not null, note: "시작 시각 (ISO 8601)"] finished_at TEXT [note: "종료 시각 (ISO 8601)"] status TEXT [not null, note: "상태 (RUNNING/COMPLETED/FAILED)"] input_source TEXT [note: "입력 소스 경로"] output_json_path TEXT [note: "출력 JSON 파일 경로"] output_db_path TEXT [note: "출력 DB 경로"] notes TEXT [note: "메모"] created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] Note: "데이터 수집 실행 기록 (레거시, V2의 kis_collection_runs 참조)" } Table quantengine.collection_snapshots { run_id TEXT [not null, note: "수집 실행 ID"] dataset_name TEXT [not null, note: "데이터셋명"] ticker TEXT [not null, note: "종목코드 (예: 005930)"] name TEXT [note: "종목명"] sector TEXT [note: "업종"] as_of_date TEXT [note: "기준 일자"] source_priority TEXT [note: "소스 우선순위"] source_status TEXT [note: "소스 상태"] payload_json TEXT [not null, note: "정규화된 데이터 (JSON)"] provenance_json TEXT [not null, note: "출처 정보 (JSON)"] created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] indexes { (run_id, dataset_name, ticker) [pk] (ticker, created_at) [name: "idx_collection_snapshots_ticker_time"] } Note: "수집 스냅샷 (레거시, V2의 kis_collection_snapshots 참조)" } Table quantengine.collection_source_errors { run_id TEXT [not null, note: "수집 실행 ID"] ticker TEXT [note: "종목코드"] source_name TEXT [not null, note: "소스명"] error_kind TEXT [not null, note: "에러 타입"] error_message TEXT [not null, note: "에러 메시지"] payload_json TEXT [note: "에러 상세 (JSON)"] created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] indexes { (run_id, source_name) [name: "idx_collection_source_errors_run"] } Note: "수집 중 발생한 에러 기록 (레거시)" } Table quantengine.settings { ordinal INT [not null, note: "순서 인덱스"] key TEXT [pk, note: "설정 키"] value_json TEXT [not null, note: "값 (JSON)"] note TEXT [not null, default: "''", note: "설명"] updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] Note: "애플리케이션 설정 저장소" } Table quantengine.account_snapshot { ordinal INT [not null, note: "순서 인덱스"] row_json TEXT [not null, note: "계정 데이터 (JSON)"] captured_at TEXT [not null, default: "''", note: "캡처 시각 (ISO 8601)"] account TEXT [not null, default: "''", note: "계정"] account_type TEXT [not null, default: "''", note: "계정 타입"] ticker TEXT [not null, default: "''", note: "종목코드"] name TEXT [not null, default: "''", note: "이름"] parse_status TEXT [not null, default: "''", note: "파싱 상태"] user_confirmed TEXT [not null, default: "''", note: "사용자 확인 여부"] updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] indexes { (captured_at) [name: "idx_account_snapshot_captured_at"] (ticker) [name: "idx_account_snapshot_ticker"] } Note: "계정 스냅샷 저장소" } Table quantengine.workspace_meta { key TEXT [pk, note: "메타 키"] value_json TEXT [not null, note: "값 (JSON)"] Note: "워크스페이스 메타데이터" } Table quantengine.workspace_change_log { id SERIAL [pk, note: "자동 증가 ID"] domain TEXT [not null, note: "도메인"] action TEXT [not null, note: "액션 (create/update/delete)"] target_ref TEXT [not null, default: "''", note: "대상 참조"] actor TEXT [not null, default: "'system'", note: "액터 (사용자/시스템)"] note TEXT [not null, default: "''", note: "메모"] before_json TEXT [not null, default: "'null'", note: "변경 전 값 (JSON)"] after_json TEXT [not null, default: "'null'", note: "변경 후 값 (JSON)"] created_at TEXT [not null, note: "기록 시각 (ISO 8601)"] Note: "변경 로그" } Table quantengine.workspace_approval_v2 { domain TEXT [not null, note: "도메인"] target_ref TEXT [not null, default: "'*'", note: "대상 참조"] status TEXT [not null, note: "승인 상태"] approved_by TEXT [not null, default: "''", note: "승인자"] approved_at TEXT [not null, default: "''", note: "승인 시각 (ISO 8601)"] note TEXT [not null, default: "''", note: "메모"] updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] indexes { (domain, target_ref) [pk] } Note: "승인 워크플로우" } Table quantengine.workspace_lock { domain TEXT [not null, note: "도메인"] target_ref TEXT [not null, default: "''", note: "대상 참조"] locked_by TEXT [not null, default: "''", note: "잠금 사용자"] reason TEXT [not null, default: "''", note: "잠금 사유"] locked_at TEXT [not null, note: "잠금 시각 (ISO 8601)"] indexes { (domain, target_ref) [pk] } Note: "동시성 제어용 잠금" } // ============================================================================= // V2: KIS 수집 파이프라인 (kis_collection_*) // ============================================================================= Table quantengine.kis_collection_runs { run_id TEXT [pk, note: "수집 실행 ID"] status TEXT [not null, note: "상태: RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED"] started_at TEXT [not null, note: "시작 시각 (ISO 8601 KST)"] finished_at TEXT [note: "종료 시각 (ISO 8601 KST)"] total_snapshots INTEGER [note: "성공한 스냅샷 수"] total_errors INTEGER [note: "발생한 에러 수"] updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"] indexes { (started_at) [name: "idx_kis_runs_started_at"] } Note: "KIS API 수집 실행 기록" } Table quantengine.kis_collection_snapshots { run_id TEXT [not null, note: "수집 실행 ID"] dataset_name TEXT [note: "데이터셋명 (예: data_feed)"] ticker TEXT [not null, note: "종목코드 (예: 005930)"] source_name TEXT [not null, note: "데이터 소스 (kis_open_api 등)"] payload_json TEXT [not null, note: "정규화된 수집 데이터 (JSON)"] captured_at TEXT [not null, note: "캡처 시각 (ISO 8601 KST)"] created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"] indexes { (run_id, ticker, source_name) [pk] (ticker) [name: "idx_kis_snapshots_ticker"] (captured_at) [name: "idx_kis_snapshots_captured_at"] } Note: "KIS API 수집 스냅샷 (시계열 데이터)" } Table quantengine.kis_collection_errors { id SERIAL [pk, note: "자동 증가 ID"] run_id TEXT [not null, note: "수집 실행 ID"] source_name TEXT [not null, note: "데이터 소스"] error_kind TEXT [not null, note: "에러 타입 (예: HttpRequestException)"] error_message TEXT [note: "에러 메시지"] ticker TEXT [note: "종목코드 (해당하면)"] created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"] indexes { (run_id) [name: "idx_kis_errors_run_id"] } Note: "KIS API 수집 중 발생한 에러" } // ============================================================================= // Schema: engine_history (V3) // ============================================================================= TableGroup "engine_history" { market_raw_history factor_version_history factor_output_history decision_result_history market_vs_engine_gap_history source_observation factor_definition factor_observation decision_event decision_factor_evidence outcome_evaluation } Table engine_history.market_raw_history { id BIGSERIAL [pk, note: "자동 증가 ID"] source_id TEXT [not null, note: "소스 ID"] observed_at TEXT [not null, note: "관측 시각 (ISO 8601)"] source_name TEXT [not null, note: "소스명 (kis_open_api 등)"] instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"] field_name TEXT [not null, note: "필드명 (현재가, 종가 등)"] field_value TEXT [not null, note: "필드값 (문자열)"] unit TEXT [not null, note: "단위 (원, % 등)"] provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] indexes { (created_at) [name: "idx_market_raw_history_created_at"] } Note: "시장 데이터 원본 이력 (정규화 전)" } Table engine_history.factor_version_history { id BIGSERIAL [pk, note: "자동 증가 ID"] factor_id TEXT [not null, note: "팩터 ID (예: momentum_ss001)"] factor_version TEXT [not null, note: "팩터 버전 (예: v1.0.0)"] effective_from TEXT [not null, note: "유효 시작 일자 (YYYYMMDD)"] effective_to TEXT [not null, note: "유효 종료 일자 (YYYYMMDD)"] formula_id TEXT [not null, note: "계산식 ID"] source_version TEXT [not null, note: "소스 버전"] provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] indexes { (created_at) [name: "idx_factor_version_history_created_at"] } Note: "팩터 버전 관리 이력" } Table engine_history.factor_output_history { id BIGSERIAL [pk, note: "자동 증가 ID"] factor_output_id TEXT [not null, note: "팩터 출력 ID"] observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"] factor_id TEXT [not null, note: "팩터 ID"] factor_version TEXT [not null, note: "팩터 버전"] output_value TEXT [not null, note: "출력값 (문자열)"] output_gate TEXT [not null, note: "게이트 (PASS/FAIL/WARN)"] source_version TEXT [not null, note: "소스 버전"] provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] indexes { (created_at) [name: "idx_factor_output_history_created_at"] } Note: "팩터 계산 결과 이력" } Table engine_history.decision_result_history { id BIGSERIAL [pk, note: "자동 증가 ID"] decision_id TEXT [not null, note: "의사결정 ID"] decided_at TEXT [not null, note: "의사결정 일자 (YYYYMMDD)"] instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"] action TEXT [not null, note: "액션 (BUY/SELL/HOLD)"] gate TEXT [not null, note: "게이트 (PASS/FAIL)"] score TEXT [not null, note: "스코어 (문자열)"] source_version TEXT [not null, note: "소스 버전"] provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] indexes { (created_at) [name: "idx_decision_result_history_created_at"] } Note: "의사결정 결과 이력" } Table engine_history.market_vs_engine_gap_history { id BIGSERIAL [pk, note: "자동 증가 ID"] gap_id TEXT [not null, note: "갭 ID"] observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"] instrument_id TEXT [not null, note: "상품 ID"] metric_name TEXT [not null, note: "지표명"] market_value TEXT [not null, note: "시장값"] engine_value TEXT [not null, note: "엔진값"] gap_value TEXT [not null, note: "갭값 (절대값)"] gap_pct TEXT [not null, note: "갭 백분율 (%)"] source_version TEXT [not null, note: "소스 버전"] provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] indexes { (created_at) [name: "idx_market_vs_engine_gap_history_created_at"] } Note: "시장 데이터 vs 엔진 계산 갭 분석 이력" } // ============================================================================= // Schema: engine_history (V5 normalized learning history) // ============================================================================= Table engine_history.source_observation { observation_id UUID [pk] observed_at TIMESTAMPTZ [not null] instrument_id TEXT [not null] source_name TEXT [not null] source_version TEXT [not null] payload JSONB [not null] provenance JSONB [not null, default: "'{}'::jsonb"] created_at TIMESTAMPTZ [not null, default: "NOW()"] } Table engine_history.factor_definition { factor_id TEXT [not null] factor_version TEXT [not null] formula_id TEXT [not null] effective_from TIMESTAMPTZ [not null] effective_to TIMESTAMPTZ definition JSONB [not null, default: "'{}'::jsonb"] provenance JSONB [not null, default: "'{}'::jsonb"] indexes { (factor_id, factor_version) [pk] } } Table engine_history.factor_observation { factor_observation_id UUID [pk] observation_id UUID [not null] factor_id TEXT [not null] factor_version TEXT [not null] observed_at TIMESTAMPTZ [not null] numeric_value NUMERIC text_value TEXT gate TEXT [not null] provenance JSONB [not null, default: "'{}'::jsonb"] } Table engine_history.decision_event { decision_id UUID [pk] decision_key TEXT [not null, unique] decided_at TIMESTAMPTZ [not null] instrument_id TEXT [not null] action TEXT [not null] gate TEXT [not null] score NUMERIC source_version TEXT [not null] trace JSONB [not null, default: "'{}'::jsonb"] provenance JSONB [not null, default: "'{}'::jsonb"] created_at TIMESTAMPTZ [not null, default: "NOW()"] } Table engine_history.decision_factor_evidence { decision_id UUID [not null] factor_observation_id UUID [not null] role TEXT [not null] indexes { (decision_id, factor_observation_id) [pk] } } Table engine_history.outcome_evaluation { evaluation_id UUID [pk] decision_id UUID [not null] horizon_days INT [not null] evaluated_at TIMESTAMPTZ [not null] realized_return NUMERIC benchmark_return NUMERIC excess_return NUMERIC outcome_class TEXT [not null] evaluation_gate TEXT [not null] provenance JSONB [not null, default: "'{}'::jsonb"] indexes { (decision_id, horizon_days) [unique] } } // ============================================================================= // Relationships (Logical, not enforced as FKs in DDL) // ============================================================================= Ref: quantengine.kis_collection_snapshots.run_id > quantengine.kis_collection_runs.run_id { // logical relationship: snapshots belong to a run } Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs.run_id { // logical relationship: errors belong to a run } Ref: quantengine.workspace_session.username > quantengine.workspace_account.username { // logical relationship: session belongs to a user } Ref: engine_history.factor_observation.observation_id > engine_history.source_observation.observation_id Ref: engine_history.factor_observation.(factor_id, factor_version) > engine_history.factor_definition.(factor_id, factor_version) Ref: engine_history.decision_factor_evidence.decision_id > engine_history.decision_event.decision_id Ref: engine_history.decision_factor_evidence.factor_observation_id > engine_history.factor_observation.factor_observation_id Ref: engine_history.outcome_evaluation.decision_id > engine_history.decision_event.decision_id