279d1760ef
V004_normalize_snapshots_schema.sql had an unguarded FK to a table V2
creates. Under DbUp's default ordinal filename sort, the zero-padded
"V003_"/"V004_" migrations sorted before "V1__", so on a brand-new
database V004 would hard-fail on that FK and abort every migration
after it - V1 through V8 would never run. Confirmed via production
that neither V003 nor V004 had ever actually applied.
Fix: renamed them to V9__/V10__ and added MigrationScriptNameComparer,
which sorts DbUp scripts by numeric V{n} value instead of raw string
order, so double-digit versions can never again sort ahead of earlier
single-digit ones. Added regression tests for both the fixed case and
the original bug shape.
Also updates docs/db/quantengine.dbml (renamed migrations, and closes
out the engine_history/quantengine table-name-collision question -
both schemas are live, backing different code paths, not duplicates)
and CLAUDE.md (migration ordering fix, KIS/OpenDART/KRX credential
env-var-name reference, including a CI secret/env-var name mismatch
found for OpenDART that still needs a decision).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
777 lines
30 KiB
Plaintext
777 lines
30 KiB
Plaintext
// =============================================================================
|
|
// QuantEngine Database Schema (DBML)
|
|
// DbUp 마이그레이션(V1~V10)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
|
|
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
|
|
//
|
|
// 2026-07-30 해결됨: 예전에 V003_name.sql/V004_name.sql(싱글언더스코어, zero-pad)이
|
|
// V1__Name.sql(더블언더스코어, zero-pad 없음) 방식과 섞여 있어, DbUp의 기본 알파벳순
|
|
// 정렬에서 "V003" < "V1"로 먼저 실행되는 문제가 있었다 (V004는 V2가 만드는 테이블에 대한
|
|
// 하드 FK 제약이 있어 빈 DB에 처음부터 배포하면 V004에서 하드 실패 → V1~V8 전체가 실행
|
|
// 안 되는 재현성 버그였음). 조치: V003→V9, V004→V10으로 리네임 + DbMigrator.cs에
|
|
// MigrationScriptNameComparer(숫자 기반 비교자)를 추가해 "V{n}"이 몇 자리 숫자든 항상
|
|
// 숫자 크기순으로 정렬되도록 함. 신규 마이그레이션은 V{n}__Name.sql 규칙만 사용할 것
|
|
// (이 비교자 덕분에 V11, V12... 로 계속 늘어나도 더 이상 이 문제가 재발하지 않는다).
|
|
//
|
|
// 참고: 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 엔진 계산 갭 분석 이력"
|
|
}
|
|
|
|
// =============================================================================
|
|
// V6: Market Time Series (quantengine schema)
|
|
// =============================================================================
|
|
|
|
Table quantengine.price_history_daily {
|
|
ticker TEXT [not null]
|
|
trade_date DATE [not null]
|
|
open NUMERIC [not null]
|
|
high NUMERIC [not null]
|
|
low NUMERIC [not null]
|
|
close NUMERIC [not null]
|
|
volume BIGINT [not null]
|
|
source TEXT [not null]
|
|
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
|
|
|
indexes {
|
|
(ticker, trade_date) [pk]
|
|
}
|
|
}
|
|
|
|
Table quantengine.macro_history_daily {
|
|
symbol TEXT [not null]
|
|
trade_date DATE [not null]
|
|
value NUMERIC [not null]
|
|
source TEXT [not null]
|
|
collected_at TIMESTAMPTZ [not null, default: "NOW()"]
|
|
provenance JSONB [not null, default: "'{}'::jsonb"]
|
|
|
|
indexes {
|
|
(symbol, trade_date) [pk]
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// V5: Normalized Learning History (engine_history schema, event-sourcing style)
|
|
// =============================================================================
|
|
|
|
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]
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// V9: Audit Trail Tables (quantengine schema, 파일: V9__Add_Audit_Trail_Tables.sql,
|
|
// 원래 이름 V003_add_audit_trail_tables.sql, 2026-07-30에 V9로 리네임 — 위 헤더 참고)
|
|
//
|
|
// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 리네임 전 이 마이그레이션은 CREATE TABLE
|
|
// 안에 MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로
|
|
// 실패했고, quantengine.schemaversions(DbUp 저널)에도 전혀 기록되어 있지 않았다 — 아래 3개
|
|
// 테이블은 프로덕션에 실제로 존재하지 않음을 확인했다. 인라인 INDEX 구문은 별도 CREATE INDEX
|
|
// 문으로 수정했고, V003→V9 리네임으로 실행 순서 문제도 해결했으므로 다음 배포 시 DbUp가 이
|
|
// 마이그레이션을 최초로 실행해 아래 3개 테이블을 생성할 것이다.
|
|
// =============================================================================
|
|
|
|
Table quantengine.kis_collection_runs_audit {
|
|
id BIGSERIAL [pk]
|
|
run_id "UUID" [not null]
|
|
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
|
|
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
|
|
change_reason TEXT
|
|
old_values JSONB
|
|
new_values JSONB
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "kis_collection_runs 변경 이력 (트리거 자동 기록) — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
|
|
}
|
|
|
|
Table quantengine.kis_collection_snapshots_audit {
|
|
id BIGSERIAL [pk]
|
|
snapshot_id "UUID" [not null]
|
|
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
|
|
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
|
|
change_reason TEXT
|
|
old_values JSONB
|
|
new_values JSONB
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "kis_collection_snapshots 변경 이력 — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
|
|
}
|
|
|
|
Table quantengine.kis_collection_errors_audit {
|
|
id BIGSERIAL [pk]
|
|
error_id "UUID" [not null]
|
|
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
|
|
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
|
|
change_reason TEXT
|
|
old_values JSONB
|
|
new_values JSONB
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "kis_collection_errors 변경 이력 — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
|
|
}
|
|
|
|
// =============================================================================
|
|
// V10: 3NF Normalization / Star Schema (quantengine schema, 파일:
|
|
// V10__Normalize_Snapshots_Schema.sql, 원래 이름 V004_normalize_snapshots_schema.sql,
|
|
// 2026-07-30에 V10로 리네임 — 위 헤더 참고. Adapter 패턴으로 기존
|
|
// kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨)
|
|
// =============================================================================
|
|
|
|
Table quantengine.stocks {
|
|
id SERIAL [pk]
|
|
ticker "VARCHAR(10)" [unique, not null]
|
|
name "VARCHAR(255)"
|
|
sector "VARCHAR(50)"
|
|
market "VARCHAR(20)" [note: "KOSPI/KOSDAQ 등"]
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
updated_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "종목 차원 테이블 (Star Schema dimension)"
|
|
}
|
|
|
|
Table quantengine.sources {
|
|
id SERIAL [pk]
|
|
name "VARCHAR(50)" [unique, not null]
|
|
priority INT [not null, note: "1=주 소스, 2 이상=폴백"]
|
|
fallback_to_id INT [ref: > quantengine.sources.id]
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "데이터 소스 차원 테이블 (KIS→Naver→Yahoo→OpenDART 폴백 체인)"
|
|
}
|
|
|
|
Table quantengine.market_data {
|
|
id BIGSERIAL [pk]
|
|
stock_id INT [not null, ref: > quantengine.stocks.id]
|
|
source_id INT [not null, ref: > quantengine.sources.id]
|
|
price DECIMAL [not null]
|
|
bid DECIMAL
|
|
ask DECIMAL
|
|
volume BIGINT
|
|
collected_at TIMESTAMPTZ [not null]
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
collection_run_id "UUID" [note: "kis_collection_runs 추적용"]
|
|
|
|
Note: "정규화된 시장 데이터 팩트 테이블 (Star Schema fact)"
|
|
}
|
|
|
|
Table quantengine.kis_collection_snapshots_v2 {
|
|
id "UUID" [pk]
|
|
run_id "UUID" [not null, ref: > quantengine.kis_collection_runs.run_id]
|
|
stock_id INT [not null, ref: > quantengine.stocks.id]
|
|
market_data_id BIGINT [ref: > quantengine.market_data.id, note: "조회 성능을 위한 의도적 역정규화"]
|
|
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
|
|
|
|
Note: "정규화된 kis_collection_snapshots — 레거시 kis_collection_snapshots와 Adapter 패턴으로 병행 운영, 완전 전환 여부 미확인"
|
|
}
|
|
|
|
// =============================================================================
|
|
// V8: PostgreSQL History-First Operating Model (quantengine schema)
|
|
//
|
|
// ⚠️ 동명이의 테이블 (통합 대상 아님, 2026-07-30 코드 조사로 확정): 아래 4개 테이블
|
|
// (market_raw_history, factor_version_history, factor_output_history,
|
|
// decision_result_history)은 engine_history 스키마(V3, 위 참고)에도 같은 이름으로 존재하지만,
|
|
// 마이그레이션 버그도 아니고 죽은 코드도 아니다 — 둘 다 실제로 읽고 쓰는 라이브 코드가 있는
|
|
// 서로 다른 두 모델이다:
|
|
// - engine_history.*: PostgresqlHistoryStore.AppendAsync() + HistoryIngestionService가
|
|
// 쓰는 범용 append-only 이력 저장소 (EAV형 원본 관측 이력)
|
|
// - quantengine.*(이 섹션): PostgresqlHistoryStore의 RecordWaterfallExecutionAsync 등
|
|
// + Admin 엔드포인트(BulkInsertMarketExcelEndpoint, UpdateFactorThresholdEndpoint,
|
|
// ExportStreamingFactorOlapEndpoint)가 쓰는 신형 구조화 운영 모델 (OHLCV 와이드 테이블 /
|
|
// 팩터ID-스코어 구조)
|
|
// 결론: 둘 다 유지해야 한다. 다만 같은 개념에 같은 테이블명을 두 스키마에서 쓰는 것 자체가
|
|
// 향후 개발자가 착각하기 쉬우므로(예: quantengine.market_raw_history에 쓸 걸 실수로
|
|
// engine_history.market_raw_history에 씀), 신규 코드 작성 시 스키마를 명시적으로 지정하고
|
|
// 반드시 어느 모델을 쓰는지 주석으로 남길 것.
|
|
// =============================================================================
|
|
|
|
Table quantengine.market_raw_history {
|
|
id BIGSERIAL [pk]
|
|
ticker "VARCHAR(32)" [not null]
|
|
as_of_date "VARCHAR(10)" [not null]
|
|
open_price "NUMERIC(18,4)"
|
|
high_price "NUMERIC(18,4)"
|
|
low_price "NUMERIC(18,4)"
|
|
close_price "NUMERIC(18,4)" [not null]
|
|
volume BIGINT
|
|
nav_price "NUMERIC(18,4)"
|
|
disparate_ratio "NUMERIC(10,6)"
|
|
tracking_error "NUMERIC(10,6)"
|
|
aum_krw "NUMERIC(20,2)"
|
|
raw_payload JSONB [not null]
|
|
provenance JSONB [not null]
|
|
created_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
indexes {
|
|
(ticker, as_of_date) [unique, name: "uk_market_raw_ticker_date"]
|
|
}
|
|
|
|
Note: "OHLCV 와이드 테이블 — engine_history.market_raw_history(EAV형)와는 별개 설계"
|
|
}
|
|
|
|
Table quantengine.factor_version_history {
|
|
factor_id "VARCHAR(64)" [pk]
|
|
formula_name "VARCHAR(128)" [not null]
|
|
version "VARCHAR(32)" [not null]
|
|
category "VARCHAR(64)" [not null]
|
|
calibration_state "VARCHAR(32)" [not null, default: "'UNTESTED'"]
|
|
threshold_params JSONB [not null]
|
|
description TEXT
|
|
updated_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
Note: "팩터 정의 — engine_history.factor_version_history와는 별개 설계 (PK가 factor_id 단독, 버전 이력 미보존)"
|
|
}
|
|
|
|
Table quantengine.factor_output_history {
|
|
id BIGSERIAL [pk]
|
|
run_id "VARCHAR(64)" [not null]
|
|
ticker "VARCHAR(32)" [not null]
|
|
as_of_date "VARCHAR(10)" [not null]
|
|
factor_id "VARCHAR(64)" [not null, ref: > quantengine.factor_version_history.factor_id]
|
|
score "NUMERIC(10,4)"
|
|
calculation_state "VARCHAR(32)" [not null]
|
|
provenance JSONB [not null]
|
|
created_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
Note: "팩터 계산 결과 — engine_history.factor_output_history와는 별개 설계"
|
|
}
|
|
|
|
Table quantengine.decision_result_history {
|
|
id BIGSERIAL [pk]
|
|
run_id "VARCHAR(64)" [unique, not null]
|
|
as_of_date "VARCHAR(10)" [not null]
|
|
market_regime "VARCHAR(32)" [not null]
|
|
portfolio_health "VARCHAR(32)" [not null]
|
|
rebalance_required BOOLEAN [not null, default: "false"]
|
|
mid_check_required BOOLEAN [not null, default: "false"]
|
|
total_asset_krw "NUMERIC(20,2)" [not null]
|
|
d2_cash_krw "NUMERIC(20,2)" [not null]
|
|
decision_packet_json JSONB [not null]
|
|
created_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
Note: "의사결정 패킷 이력 — engine_history.decision_result_history와는 별개 설계"
|
|
}
|
|
|
|
Table quantengine.order_waterfall_execution_history {
|
|
id BIGSERIAL [pk]
|
|
run_id "VARCHAR(64)" [not null, ref: > quantengine.decision_result_history.run_id]
|
|
ticker "VARCHAR(32)" [not null]
|
|
sell_priority_rank INT [not null]
|
|
waterfall_stage "VARCHAR(64)" [not null]
|
|
action "VARCHAR(16)" [not null]
|
|
target_qty INT [not null]
|
|
executed_qty INT [default: "0"]
|
|
target_price "NUMERIC(18,4)"
|
|
executed_price "NUMERIC(18,4)"
|
|
bid_ask_spread_bps "NUMERIC(10,2)"
|
|
slippage_bps "NUMERIC(10,2)"
|
|
status "VARCHAR(32)" [not null]
|
|
rationale TEXT
|
|
created_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
Note: "매도 워터폴 실행 이력"
|
|
}
|
|
|
|
Table quantengine.shadow_ledger_history {
|
|
id BIGSERIAL [pk]
|
|
run_id "VARCHAR(64)" [not null, ref: > quantengine.decision_result_history.run_id]
|
|
ticker "VARCHAR(32)" [not null]
|
|
blocked_gate "VARCHAR(64)" [not null]
|
|
blocked_reason TEXT [not null]
|
|
shadow_price "NUMERIC(18,4)" [not null]
|
|
shadow_qty INT [not null]
|
|
shadow_tp_price "NUMERIC(18,4)"
|
|
shadow_sl_price "NUMERIC(18,4)"
|
|
created_at TIMESTAMPTZ [default: "NOW()"]
|
|
|
|
Note: "게이트에 막힌 주문의 가상 체결 감사 기록 (Shadow Ledger)"
|
|
}
|
|
|
|
Table quantengine.scheduler_state_history {
|
|
id BIGSERIAL [pk]
|
|
task_name "VARCHAR(64)" [not null]
|
|
execution_id "VARCHAR(64)" [unique, not null]
|
|
state "VARCHAR(32)" [not null]
|
|
started_at TIMESTAMPTZ [not null, default: "NOW()"]
|
|
finished_at TIMESTAMPTZ
|
|
error_message TEXT
|
|
lock_token "VARCHAR(64)"
|
|
|
|
indexes {
|
|
(task_name, state) [name: "idx_scheduler_state_task"]
|
|
}
|
|
|
|
Note: "스케줄러 작업 상태 머신 이력"
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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
|