diff --git a/docs/db/quantengine.dbml b/docs/db/quantengine.dbml index 89e6ec0e..eccaec53 100644 --- a/docs/db/quantengine.dbml +++ b/docs/db/quantengine.dbml @@ -1,6 +1,6 @@ // ============================================================================= // QuantEngine Database Schema (DBML) -// DbUp 마이그레이션(V1~V3)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 +// DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 // (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화) // // 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성 @@ -277,6 +277,12 @@ TableGroup "engine_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 { @@ -375,6 +381,88 @@ Table engine_history.market_vs_engine_gap_history { 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) // ============================================================================= @@ -390,3 +478,9 @@ Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs. 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 diff --git a/src/dotnet/QuantEngine.Core/Interfaces/INormalizedLearningStore.cs b/src/dotnet/QuantEngine.Core/Interfaces/INormalizedLearningStore.cs new file mode 100644 index 00000000..5f14cd0c --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Interfaces/INormalizedLearningStore.cs @@ -0,0 +1,51 @@ +namespace QuantEngine.Core.Interfaces; + +public interface INormalizedLearningStore +{ + Task AppendSourceObservationAsync(SourceObservationRecord record); + Task AppendFactorObservationAsync(FactorObservationRecord record); + Task AppendDecisionAsync(DecisionEventRecord record); + Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role); + Task AppendOutcomeAsync(OutcomeEvaluationRecord record); +} + +public sealed record SourceObservationRecord( + DateTimeOffset ObservedAt, + string InstrumentId, + string SourceName, + string SourceVersion, + string PayloadJson, + string ProvenanceJson); + +public sealed record FactorObservationRecord( + Guid ObservationId, + Guid FactorObservationId, + string FactorId, + string FactorVersion, + DateTimeOffset ObservedAt, + decimal? NumericValue, + string? TextValue, + string Gate, + string ProvenanceJson); + +public sealed record DecisionEventRecord( + string DecisionKey, + DateTimeOffset DecidedAt, + string InstrumentId, + string Action, + string Gate, + decimal? Score, + string SourceVersion, + string TraceJson, + string ProvenanceJson); + +public sealed record OutcomeEvaluationRecord( + Guid DecisionId, + int HorizonDays, + DateTimeOffset EvaluatedAt, + decimal? RealizedReturn, + decimal? BenchmarkReturn, + decimal? ExcessReturn, + string OutcomeClass, + string EvaluationGate, + string ProvenanceJson); diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/NormalizedLearningStore.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/NormalizedLearningStore.cs new file mode 100644 index 00000000..ac8b6122 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/NormalizedLearningStore.cs @@ -0,0 +1,82 @@ +using Dapper; +using QuantEngine.Core.Interfaces; +using QuantEngine.Infrastructure.Data; + +namespace QuantEngine.Infrastructure.Repositories; + +public sealed class NormalizedLearningStore : INormalizedLearningStore +{ + private readonly IDbConnectionFactory _connectionFactory; + + public NormalizedLearningStore(IDbConnectionFactory connectionFactory) => _connectionFactory = connectionFactory; + + public async Task AppendSourceObservationAsync(SourceObservationRecord record) + { + var id = Guid.NewGuid(); + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@"INSERT INTO engine_history.source_observation + (observation_id, observed_at, instrument_id, source_name, source_version, payload, provenance) + VALUES (@Id, @ObservedAt, @InstrumentId, @SourceName, @SourceVersion, + CAST(@PayloadJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.ObservedAt, record.InstrumentId, record.SourceName, record.SourceVersion, record.PayloadJson, record.ProvenanceJson }); + return id; + } + + public async Task AppendFactorObservationAsync(FactorObservationRecord record) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@"INSERT INTO engine_history.factor_observation + (factor_observation_id, observation_id, factor_id, factor_version, observed_at, + numeric_value, text_value, gate, provenance) + VALUES (@FactorObservationId, @ObservationId, @FactorId, @FactorVersion, @ObservedAt, + @NumericValue, @TextValue, @Gate, CAST(@ProvenanceJson AS jsonb))", new + { + record.FactorObservationId, + record.ObservationId, + record.FactorId, + record.FactorVersion, + record.ObservedAt, + record.NumericValue, + record.TextValue, + record.Gate, + record.ProvenanceJson + }); + return record.FactorObservationId; + } + + public async Task AppendDecisionAsync(DecisionEventRecord record) + { + var id = Guid.NewGuid(); + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_event + (decision_id, decision_key, decided_at, instrument_id, action, gate, score, + source_version, trace, provenance) + VALUES (@Id, @DecisionKey, @DecidedAt, @InstrumentId, @Action, @Gate, @Score, + @SourceVersion, CAST(@TraceJson AS jsonb), CAST(@ProvenanceJson AS jsonb))", new { Id = id, record.DecisionKey, record.DecidedAt, record.InstrumentId, record.Action, record.Gate, record.Score, record.SourceVersion, record.TraceJson, record.ProvenanceJson }); + return id; + } + + public async Task AppendDecisionFactorEvidenceAsync(Guid decisionId, Guid factorObservationId, string role) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@"INSERT INTO engine_history.decision_factor_evidence + (decision_id, factor_observation_id, role) VALUES (@DecisionId, @FactorObservationId, @Role)", new { decisionId, factorObservationId, role }); + } + + public async Task AppendOutcomeAsync(OutcomeEvaluationRecord record) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@"INSERT INTO engine_history.outcome_evaluation + (decision_id, horizon_days, evaluated_at, realized_return, benchmark_return, + excess_return, outcome_class, evaluation_gate, provenance) + VALUES (@DecisionId, @HorizonDays, @EvaluatedAt, @RealizedReturn, @BenchmarkReturn, + @ExcessReturn, @OutcomeClass, @EvaluationGate, CAST(@ProvenanceJson AS jsonb)) + ON CONFLICT (decision_id, horizon_days) DO UPDATE SET + evaluated_at = EXCLUDED.evaluated_at, + realized_return = EXCLUDED.realized_return, + benchmark_return = EXCLUDED.benchmark_return, + excess_return = EXCLUDED.excess_return, + outcome_class = EXCLUDED.outcome_class, + evaluation_gate = EXCLUDED.evaluation_gate, + provenance = EXCLUDED.provenance", record); + } +} diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 37521149..194bdd57 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -96,6 +96,7 @@ try // Repository Services builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();