From 1d905808542395624a7f94664ee72e91be0ad8e4 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 12 Jul 2026 11:44:00 +0900 Subject: [PATCH] feat: add normalized PostgreSQL learning history --- spec/02_data_contract.yaml | 6 +- .../V5__Add_Normalized_Learning_History.sql | 117 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql diff --git a/spec/02_data_contract.yaml b/spec/02_data_contract.yaml index 0b70e685..d9fff335 100644 --- a/spec/02_data_contract.yaml +++ b/spec/02_data_contract.yaml @@ -176,7 +176,9 @@ quant_feed_contract: database_first_operating_model: purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다." canonical_store: - primary: "PostgreSQL" + primary: "PostgreSQL" + canonical_engine_history_migration: "src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql" + learning_projection: "engine_history.training_example_v1" secondary: "SQLite transient cache only" prohibited_operating_path: - "Excel workbook as operational source" @@ -190,6 +192,8 @@ quant_feed_contract: policy: - "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다." - "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다." + - "원천 관측·factor·decision·outcome은 정규화된 PostgreSQL event store에 적재한다." + - "학습/캘리브레이션 입력은 training_example_v1 역정규화 view에서만 생성한다." - "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다." - "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다." xlsx_analysis_protocol: diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql new file mode 100644 index 00000000..ad1b3d00 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Migrations/V5__Add_Normalized_Learning_History.sql @@ -0,0 +1,117 @@ +-- V5__Add_Normalized_Learning_History.sql +-- Normalized PostgreSQL event store for factor decisions and learning data. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE SCHEMA IF NOT EXISTS engine_history; + +CREATE TABLE IF NOT EXISTS engine_history.source_observation ( + observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + 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() +); +CREATE INDEX IF NOT EXISTS ix_source_observation_instrument_time + ON engine_history.source_observation (instrument_id, observed_at DESC); + +CREATE TABLE IF NOT EXISTS 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, + PRIMARY KEY (factor_id, factor_version) +); + +CREATE TABLE IF NOT EXISTS engine_history.factor_observation ( + factor_observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + observation_id UUID NOT NULL REFERENCES engine_history.source_observation(observation_id), + 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, + CONSTRAINT fk_factor_definition + FOREIGN KEY (factor_id, factor_version) + REFERENCES engine_history.factor_definition(factor_id, factor_version), + CONSTRAINT factor_value_present CHECK (numeric_value IS NOT NULL OR text_value IS NOT NULL) +); +CREATE INDEX IF NOT EXISTS ix_factor_observation_factor_time + ON engine_history.factor_observation (factor_id, observed_at DESC); + +CREATE TABLE IF NOT EXISTS engine_history.decision_event ( + decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + 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() +); +CREATE INDEX IF NOT EXISTS ix_decision_event_instrument_time + ON engine_history.decision_event (instrument_id, decided_at DESC); + +CREATE TABLE IF NOT EXISTS engine_history.decision_factor_evidence ( + decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id), + factor_observation_id UUID NOT NULL REFERENCES engine_history.factor_observation(factor_observation_id), + role TEXT NOT NULL, + PRIMARY KEY (decision_id, factor_observation_id) +); + +CREATE TABLE IF NOT EXISTS engine_history.outcome_evaluation ( + evaluation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + decision_id UUID NOT NULL REFERENCES engine_history.decision_event(decision_id), + horizon_days INTEGER NOT NULL CHECK (horizon_days > 0), + 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, + UNIQUE (decision_id, horizon_days) +); + +-- Read-optimized projection for model training and calibration jobs. +CREATE OR REPLACE VIEW engine_history.training_example_v1 AS +SELECT + d.decision_id, + d.decision_key, + d.decided_at, + d.instrument_id, + d.action, + d.gate AS decision_gate, + d.score, + d.source_version, + e.horizon_days, + e.realized_return, + e.benchmark_return, + e.excess_return, + e.outcome_class, + e.evaluation_gate, + jsonb_agg(jsonb_build_object( + 'factor_id', f.factor_id, + 'factor_version', f.factor_version, + 'numeric_value', f.numeric_value, + 'text_value', f.text_value, + 'gate', f.gate, + 'role', evidence.role + ) ORDER BY f.factor_id) AS factor_features +FROM engine_history.decision_event d +JOIN engine_history.outcome_evaluation e ON e.decision_id = d.decision_id +JOIN engine_history.decision_factor_evidence evidence ON evidence.decision_id = d.decision_id +JOIN engine_history.factor_observation f ON f.factor_observation_id = evidence.factor_observation_id +GROUP BY d.decision_id, d.decision_key, d.decided_at, d.instrument_id, d.action, + d.gate, d.score, d.source_version, e.horizon_days, e.realized_return, + e.benchmark_return, e.excess_return, e.outcome_class, e.evaluation_gate;