feat: postgres history-first 계약과 적재 경로 추가

- PostgreSQL history contract와 schema/validator를 추가했습니다.
- .NET history store, snapshot reader, repository, migration을 연결했습니다.
- history-first 운영 모델 문서와 daily signal tracking 문구를 정리했습니다.
This commit is contained in:
2026-06-26 14:17:04 +09:00
parent 7e0c0b6c8f
commit 8f13bb4a48
17 changed files with 707 additions and 17 deletions
@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services
{
public class HistoryIngestionService
{
private readonly IPostgresqlHistoryStore _store;
public HistoryIngestionService(IPostgresqlHistoryStore store)
{
_store = store;
}
public Task<int> AppendDecisionAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("decision_result_history", payload);
public Task<int> AppendFactorOutputAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("factor_output_history", payload);
public Task<int> AppendMarketRawAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("market_raw_history", payload);
public Task<int> AppendGapAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("market_vs_engine_gap_history", payload);
public Task<int> AppendDecisionAsync(
FinalDecisionResult decision,
SellDecisionResult? sellDecision = null,
TimingDecisionResult? timingDecision = null,
string? instrumentId = null,
string? sourceVersion = null,
string? gate = null)
{
var payload = new Dictionary<string, object?>
{
["decision_id"] = Guid.NewGuid().ToString("N"),
["decided_at"] = DateTimeOffset.UtcNow,
["instrument_id"] = instrumentId ?? string.Empty,
["action"] = decision.FinalAction,
["gate"] = gate ?? (string.IsNullOrWhiteSpace(sellDecision?.Validation) ? "PASS" : sellDecision.Validation),
["score"] = decision.PriorityScore,
["source_version"] = sourceVersion ?? decision.DecisionSource,
["provenance"] = new Dictionary<string, object?>
{
["final_action"] = decision.FinalAction,
["action_priority"] = decision.ActionPriority,
["priority_score"] = decision.PriorityScore,
["decision_source"] = decision.DecisionSource,
["sell_action"] = sellDecision?.Action,
["sell_validation"] = sellDecision?.Validation,
["timing_action"] = timingDecision?.Action,
["timing_reason"] = timingDecision?.Reason
}
};
return _store.AppendAsync("decision_result_history", payload);
}
public Task<int> AppendFactorOutputAsync(
string factorId,
string factorVersion,
double outputValue,
string outputGate,
string? sourceVersion = null,
DateTimeOffset? observedAt = null)
{
var payload = new Dictionary<string, object?>
{
["factor_output_id"] = Guid.NewGuid().ToString("N"),
["observed_at"] = observedAt ?? DateTimeOffset.UtcNow,
["factor_id"] = factorId,
["factor_version"] = factorVersion,
["output_value"] = outputValue,
["output_gate"] = outputGate,
["source_version"] = sourceVersion ?? factorVersion,
["provenance"] = new Dictionary<string, object?>
{
["factor_id"] = factorId,
["factor_version"] = factorVersion,
["output_value"] = outputValue,
["output_gate"] = outputGate,
["source_version"] = sourceVersion ?? factorVersion
}
};
return _store.AppendAsync("factor_output_history", payload);
}
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services
{
public class PostgresqlHistorySnapshotReader : IPostgresqlHistorySnapshotReader
{
private readonly IPostgresqlHistoryStore _store;
public PostgresqlHistorySnapshotReader(IPostgresqlHistoryStore store)
{
_store = store;
}
public Task<IReadOnlyList<IDictionary<string, object?>>> ReadAsync(string domain, int limit = 500)
=> _store.SnapshotAsync(domain, limit);
}
}
@@ -9,10 +9,12 @@ namespace QuantEngine.Application.Services
public class WorkspaceService
{
private readonly IWorkspaceRepository _repository;
private readonly IPostgresqlHistoryStore _historyStore;
public WorkspaceService(IWorkspaceRepository repository)
public WorkspaceService(IWorkspaceRepository repository, IPostgresqlHistoryStore historyStore)
{
_repository = repository;
_historyStore = historyStore;
}
public Task<IEnumerable<Setting>> GetSettingsAsync() => _repository.GetSettingsAsync();
@@ -23,5 +25,8 @@ namespace QuantEngine.Application.Services
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync();
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots);
public Task<bool> ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync();
public Task<int> AppendHistoryAsync(string domain, IDictionary<string, object?> payload) => _historyStore.AppendAsync(domain, payload);
public Task<IReadOnlyList<IDictionary<string, object?>>> ReadHistorySnapshotAsync(string domain, int limit = 500) => _historyStore.SnapshotAsync(domain, limit);
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace QuantEngine.Core.Interfaces
{
public interface IPostgresqlHistorySnapshotReader
{
Task<IReadOnlyList<IDictionary<string, object?>>> ReadAsync(string domain, int limit = 500);
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace QuantEngine.Core.Interfaces
{
public interface IPostgresqlHistoryStore
{
Task<int> AppendAsync(string domain, IDictionary<string, object?> payload);
Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500);
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace QuantEngine.Core.Models
{
public class HistoryRow
{
public string Domain { get; set; } = string.Empty;
public IDictionary<string, object?> Payload { get; set; } = new Dictionary<string, object?>();
}
}
@@ -156,6 +156,87 @@ namespace QuantEngine.Infrastructure.Data
PRIMARY KEY (domain, target_ref)
);
");
// 10. engine_history schema and tables
conn.Execute(@"
CREATE SCHEMA IF NOT EXISTS engine_history;
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.market_raw_history (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
source_name TEXT NOT NULL,
instrument_id TEXT NOT NULL,
field_name TEXT NOT NULL,
field_value TEXT NOT NULL,
unit TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_market_raw_history_created_at ON engine_history.market_raw_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.factor_version_history (
id BIGSERIAL PRIMARY KEY,
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
effective_from TEXT NOT NULL,
effective_to TEXT NOT NULL,
formula_id TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_factor_version_history_created_at ON engine_history.factor_version_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.factor_output_history (
id BIGSERIAL PRIMARY KEY,
factor_output_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
output_value TEXT NOT NULL,
output_gate TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_factor_output_history_created_at ON engine_history.factor_output_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.decision_result_history (
id BIGSERIAL PRIMARY KEY,
decision_id TEXT NOT NULL,
decided_at TEXT NOT NULL,
instrument_id TEXT NOT NULL,
action TEXT NOT NULL,
gate TEXT NOT NULL,
score TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_decision_result_history_created_at ON engine_history.decision_result_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.market_vs_engine_gap_history (
id BIGSERIAL PRIMARY KEY,
gap_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
instrument_id TEXT NOT NULL,
metric_name TEXT NOT NULL,
market_value TEXT NOT NULL,
engine_value TEXT NOT NULL,
gap_value TEXT NOT NULL,
gap_pct TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_market_vs_engine_gap_history_created_at ON engine_history.market_vs_engine_gap_history (created_at DESC);
");
}
}
}
@@ -0,0 +1,67 @@
using System.Data;
using System.Text.Json;
using Dapper;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Infrastructure.Repositories
{
public class PostgresqlHistoryStore : IPostgresqlHistoryStore
{
private readonly IDbConnectionFactory _connectionFactory;
private static readonly IReadOnlyDictionary<string, string[]> DomainColumns = new Dictionary<string, string[]>
{
["market_raw_history"] = new[] { "source_id", "observed_at", "source_name", "instrument_id", "field_name", "field_value", "unit" },
["factor_version_history"] = new[] { "factor_id", "factor_version", "effective_from", "effective_to", "formula_id", "source_version" },
["factor_output_history"] = new[] { "factor_output_id", "observed_at", "factor_id", "factor_version", "output_value", "output_gate", "source_version" },
["decision_result_history"] = new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version" },
["market_vs_engine_gap_history"] = new[] { "gap_id", "observed_at", "instrument_id", "metric_name", "market_value", "engine_value", "gap_value", "gap_pct", "source_version" }
};
public PostgresqlHistoryStore(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
if (!DomainColumns.TryGetValue(domain, out var columns))
throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain));
using var conn = _connectionFactory.CreateConnection();
conn.Open();
var values = new DynamicParameters();
var insertColumns = new List<string>(columns.Length + 1);
var placeholders = new List<string>(columns.Length + 1);
foreach (var column in columns)
{
insertColumns.Add(column);
placeholders.Add($"@{column}");
values.Add(column, payload.TryGetValue(column, out var value) ? value : null);
}
insertColumns.Add("provenance");
placeholders.Add("@provenance");
var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>();
values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance));
var sql = $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", placeholders)})";
return await conn.ExecuteAsync(sql, values);
}
public async Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
{
if (!DomainColumns.ContainsKey(domain))
throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain));
using var conn = _connectionFactory.CreateConnection();
conn.Open();
var sql = $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit";
var rows = await conn.QueryAsync(sql, new { Limit = limit });
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
}
}
}