From 1e6bf702bcea192dbebb58a39d06f146d66ec72e Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 26 Jun 2026 18:06:36 +0900 Subject: [PATCH] core services and tests --- .../Services/CollectionService.cs | 60 +++++++ .../Services/FormulaService.cs | 41 +++++ .../ApplicationServiceTests.cs | 159 ++++++++++++++++++ .../FormulaEngineTests.cs | 58 +++++++ .../HistoryIngestionE2ETests.cs | 93 ++++++++++ .../PostgresqlHistoryStoreTests.cs | 41 +++++ .../QuantEngine.Core.Tests.csproj | 4 +- .../QuantEngine.Core.Tests/SecurityTests.cs | 68 ++++++++ .../QuantEngine.Core.Tests/UnitTest1.cs | 79 ++++++++- .../Infrastructure/OperationalReportLoader.cs | 62 +++++++ .../Properties/AssemblyInfo.cs | 3 + .../Repositories/PostgresqlHistoryStore.cs | 15 +- 12 files changed, 675 insertions(+), 8 deletions(-) create mode 100644 src/dotnet/QuantEngine.Application/Services/CollectionService.cs create mode 100644 src/dotnet/QuantEngine.Application/Services/FormulaService.cs create mode 100644 src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs create mode 100644 src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs create mode 100644 src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs create mode 100644 src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs create mode 100644 src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs create mode 100644 src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs diff --git a/src/dotnet/QuantEngine.Application/Services/CollectionService.cs b/src/dotnet/QuantEngine.Application/Services/CollectionService.cs new file mode 100644 index 0000000..b01ec61 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/CollectionService.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; + +namespace QuantEngine.Application.Services +{ + public class CollectionService + { + private readonly IPostgresqlHistoryStore _historyStore; + + public CollectionService(IPostgresqlHistoryStore historyStore) + { + _historyStore = historyStore; + } + + public Task AppendRunAsync(CollectionRun run) + => _historyStore.AppendAsync("collection_run_history", new Dictionary + { + ["run_id"] = run.RunId, + ["collector_name"] = run.CollectorName, + ["started_at"] = run.StartedAt, + ["finished_at"] = run.FinishedAt, + ["status"] = run.Status, + ["input_source"] = run.InputSource, + ["output_json_path"] = run.OutputJsonPath, + ["output_db_path"] = run.OutputDbPath, + ["notes"] = run.Notes, + ["created_at"] = run.CreatedAt + }); + + public Task AppendSnapshotAsync(CollectionSnapshot snapshot) + => _historyStore.AppendAsync("collection_snapshot_history", new Dictionary + { + ["run_id"] = snapshot.RunId, + ["dataset_name"] = snapshot.DatasetName, + ["ticker"] = snapshot.Ticker, + ["name"] = snapshot.Name, + ["sector"] = snapshot.Sector, + ["as_of_date"] = snapshot.AsOfDate, + ["source_priority"] = snapshot.SourcePriority, + ["source_status"] = snapshot.SourceStatus, + ["payload_json"] = snapshot.PayloadJson, + ["provenance_json"] = snapshot.ProvenanceJson, + ["created_at"] = snapshot.CreatedAt + }); + + public Task AppendSourceErrorAsync(CollectionSourceError error) + => _historyStore.AppendAsync("collection_source_error_history", new Dictionary + { + ["run_id"] = error.RunId, + ["ticker"] = error.Ticker, + ["source_name"] = error.SourceName, + ["error_kind"] = error.ErrorKind, + ["error_message"] = error.ErrorMessage, + ["payload_json"] = error.PayloadJson, + ["created_at"] = error.CreatedAt + }); + } +} diff --git a/src/dotnet/QuantEngine.Application/Services/FormulaService.cs b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs new file mode 100644 index 0000000..21950af --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Application.Services +{ + public class FormulaService + { + private readonly IPostgresqlHistoryStore _historyStore; + + public FormulaService(IPostgresqlHistoryStore historyStore) + { + _historyStore = historyStore; + } + + public TimingDecisionResult ComputeTimingDecision(Dictionary ctx) + => FormulaEngine.ComputeTimingDecision(ctx); + + public SellDecisionResult ComputeSellDecision(Dictionary ctx) + => FormulaEngine.ComputeSellDecision(ctx); + + public FinalDecisionResult ComputeFinalDecision(Dictionary ctx) + => FormulaEngine.ComputeFinalDecision(ctx); + + public CashShortfallResult ComputeCashShortfallHarness( + Dictionary asResult, + double totalAsset, + Dictionary cashFloorInfo, + double mrsScore) + => FormulaEngine.ComputeCashShortfallHarness(asResult, totalAsset, cashFloorInfo, mrsScore); + + public CashRecoveryPlanResult ComputeCashRecoveryOptimizer( + List> sellCandidates, + double cashShortfallMinKrw) + => FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw); + + public Task AppendFormulaRunAsync(string formulaName, Dictionary payload) + => _historyStore.AppendAsync($"formula_{formulaName}_history", payload); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs new file mode 100644 index 0000000..7393507 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs @@ -0,0 +1,159 @@ +using QuantEngine.Application.Services; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; + +namespace QuantEngine.Core.Tests; + +public class ApplicationServiceTests +{ + [Fact] + public async Task WorkspaceService_ForwardsSettingAndHistoryOperations() + { + var repo = new FakeWorkspaceRepository(); + var history = new FakeHistoryStore(); + var service = new WorkspaceService(repo, history); + + var setting = new Setting { Ordinal = 1, Key = "risk_mode", ValueJson = "\"RISK_ON\"" }; + Assert.True(await service.UpsertSettingAsync(setting)); + Assert.Equal(setting, repo.LastSetting); + + var payload = new Dictionary { ["foo"] = "bar" }; + Assert.Equal(1, await service.AppendHistoryAsync("decision_result_history", payload)); + Assert.Equal("decision_result_history", history.LastDomain); + Assert.Equal("bar", history.LastPayload?["foo"]); + } + + [Fact] + public async Task ApprovalService_ForwardsApprovalAndLockOperations() + { + var repo = new FakeWorkspaceRepository(); + var service = new ApprovalService(repo); + + var approval = new WorkspaceApproval { Domain = "settings", TargetRef = "portfolio", Status = "APPROVED" }; + Assert.True(await service.UpsertApprovalAsync(approval)); + Assert.Equal(approval, repo.LastApproval); + + var lockRow = new WorkspaceLock { Domain = "settings", TargetRef = "portfolio", LockedBy = "qa", Reason = "review" }; + Assert.True(await service.AcquireLockAsync(lockRow)); + Assert.Equal(lockRow, repo.LastLock); + Assert.True(await service.ReleaseLockAsync("settings", "portfolio")); + Assert.Equal(("settings", "portfolio"), repo.LastReleasedLock); + } + + [Fact] + public async Task CollectionService_AppendsRunSnapshotAndErrorRecords() + { + var history = new FakeHistoryStore(); + var service = new CollectionService(history); + + await service.AppendRunAsync(new CollectionRun + { + RunId = "run-1", + CollectorName = "kis", + StartedAt = "2026-06-26T09:00:00+09:00", + Status = "PASS" + }); + + Assert.Equal("collection_run_history", history.LastDomain); + Assert.Equal("run-1", history.LastPayload?["run_id"]); + + await service.AppendSnapshotAsync(new CollectionSnapshot + { + RunId = "run-1", + DatasetName = "decision_result_history", + Ticker = "005930", + SourcePriority = "KIS", + SourceStatus = "PASS", + PayloadJson = "{}", + ProvenanceJson = "{}" + }); + + Assert.Equal("collection_snapshot_history", history.LastDomain); + Assert.Equal("005930", history.LastPayload?["ticker"]); + + await service.AppendSourceErrorAsync(new CollectionSourceError + { + RunId = "run-1", + SourceName = "naver", + ErrorKind = "TIMEOUT", + ErrorMessage = "timeout" + }); + + Assert.Equal("collection_source_error_history", history.LastDomain); + Assert.Equal("TIMEOUT", history.LastPayload?["error_kind"]); + } + + [Fact] + public async Task FormulaService_ForwardsFormulaExecutionAndHistory() + { + var history = new FakeHistoryStore(); + var service = new FormulaService(history); + + var timing = service.ComputeTimingDecision(new Dictionary + { + ["entryModeGate"] = "PASS", + ["entryMode"] = "BREAKOUT", + ["leaderGate"] = "PASS", + ["acGate"] = "CLEAR", + ["priceStatus"] = "PRICE_OK", + ["atr20"] = 1.0, + ["leaderTotal"] = 4, + ["flowCredit"] = 0.7, + ["avgTradeValue5D"] = 100, + ["spreadPct"] = 0.5 + }); + + Assert.NotEqual(string.Empty, timing.Action); + + await service.AppendFormulaRunAsync("timing", new Dictionary + { + ["action"] = timing.Action, + ["entry_score"] = timing.EntryScore + }); + + Assert.Equal("formula_timing_history", history.LastDomain); + Assert.Equal(timing.Action, history.LastPayload?["action"]); + } + + private sealed class FakeWorkspaceRepository : IWorkspaceRepository + { + public Setting? LastSetting { get; private set; } + public WorkspaceApproval? LastApproval { get; private set; } + public WorkspaceLock? LastLock { get; private set; } + public (string Domain, string TargetRef)? LastReleasedLock { get; private set; } + + public Task> GetSettingsAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetSettingByKeyAsync(string key) => Task.FromResult(null); + public Task UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); } + public Task DeleteSettingAsync(string key) => Task.FromResult(true); + + public Task> GetAccountSnapshotsAsync() => Task.FromResult(Enumerable.Empty()); + public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => Task.FromResult(true); + public Task ClearAccountSnapshotsAsync() => Task.FromResult(true); + + public Task> GetApprovalsAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetApprovalAsync(string domain, string targetRef) => Task.FromResult(null); + public Task UpsertApprovalAsync(WorkspaceApproval approval) { LastApproval = approval; return Task.FromResult(true); } + + public Task> GetLocksAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetLockAsync(string domain, string targetRef) => Task.FromResult(null); + public Task AcquireLockAsync(WorkspaceLock @lock) { LastLock = @lock; return Task.FromResult(true); } + public Task ReleaseLockAsync(string domain, string targetRef) { LastReleasedLock = (domain, targetRef); return Task.FromResult(true); } + } + + private sealed class FakeHistoryStore : IPostgresqlHistoryStore + { + public string? LastDomain { get; private set; } + public IDictionary? LastPayload { get; private set; } + + public Task AppendAsync(string domain, IDictionary payload) + { + LastDomain = domain; + LastPayload = new Dictionary(payload); + return Task.FromResult(1); + } + + public Task>> SnapshotAsync(string domain, int limit = 500) + => Task.FromResult>>(Array.Empty>()); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs index 63140c6..ed3228b 100644 --- a/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs @@ -30,4 +30,62 @@ public class FormulaEngineTests Assert.NotNull(result); Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action); } + + [Fact] + public void ComputeSellDecisionProducesExitTrimWhenRiskWindowIsOpen() + { + var ctx = new Dictionary + { + { "close", 100.0 }, + { "profitPct", 31.0 }, + { "tp1Price", 108.0 }, + { "tp2Price", 112.0 }, + { "timingAction", "BUY_STAGE1_READY" }, + { "atr20", 4.0 } + }; + + var result = FormulaEngine.ComputeSellDecision(ctx); + + Assert.Equal("PROFIT_TRIM_35", result.Action); + Assert.Equal(35, result.RatioPct); + Assert.Equal("SIGNAL_CONFIRMED", result.Validation); + } + + [Fact] + public void ComputeFinalDecisionPromotesSellReadyWhenSellSignalIsConfirmed() + { + var ctx = new Dictionary + { + { "sellAction", "TRIM_35" }, + { "sellValidation", "SIGNAL_CONFIRMED" }, + { "timingScoreEntry", 72.0 }, + { "timingScoreExit", 15.0 } + }; + + var result = FormulaEngine.ComputeFinalDecision(ctx); + + Assert.Equal("SELL_READY", result.FinalAction); + Assert.Equal(10, result.ActionPriority); + Assert.Equal("RULE_ENGINE", result.DecisionSource); + } + + [Fact] + public void ComputeCashShortfallHarnessCalculatesTargetAndShortfall() + { + var asResult = new Dictionary + { + { "settlementCashD2Krw", 10_000_000.0 } + }; + var cashFloor = new Dictionary + { + { "minPct", 15.0 } + }; + + var result = FormulaEngine.ComputeCashShortfallHarness(asResult, 100_000_000.0, cashFloor, 6.0); + + Assert.Equal(10.0, result.CashCurrentPctD2); + Assert.Equal(15.0, result.CashTargetPct); + Assert.Equal(5_000_000.0, result.CashShortfallMinKrw); + Assert.Equal(5_000_000.0, result.CashShortfallTargetKrw); + } } diff --git a/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs new file mode 100644 index 0000000..6a5109c --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs @@ -0,0 +1,93 @@ +using QuantEngine.Application.Services; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Core.Tests; + +public class HistoryIngestionE2ETests +{ + [Fact] + public async Task AppendDecisionThenReadSnapshotRoundTripsThroughApplicationFlow() + { + var store = new FakeHistoryStore(); + var ingestion = new HistoryIngestionService(store); + var reader = new PostgresqlHistorySnapshotReader(store); + + var appendCount = await ingestion.AppendDecisionAsync(new Dictionary + { + ["decision_id"] = "dec-001", + ["decided_at"] = DateTimeOffset.Parse("2026-06-26T09:00:00+09:00"), + ["instrument_id"] = "005930", + ["action"] = "BUY", + ["gate"] = "PASS", + ["score"] = 87.5, + ["source_version"] = "v1", + ["provenance"] = new Dictionary + { + ["source"] = "unit-test" + } + }); + + Assert.Equal(1, appendCount); + + var rows = await reader.ReadAsync("decision_result_history", 10); + Assert.Single(rows); + Assert.Equal("dec-001", rows[0]["decision_id"]); + Assert.Equal("005930", rows[0]["instrument_id"]); + Assert.Equal("BUY", rows[0]["action"]); + Assert.Equal("PASS", rows[0]["gate"]); + Assert.Equal(87.5, rows[0]["score"]); + } + + [Fact] + public async Task AppendFactorOutputThenReadSnapshotPreservesPayload() + { + var store = new FakeHistoryStore(); + var ingestion = new HistoryIngestionService(store); + var reader = new PostgresqlHistorySnapshotReader(store); + + var appendCount = await ingestion.AppendFactorOutputAsync( + factorId: "RS_VERDICT_V2", + factorVersion: "2026-06-26", + outputValue: 1.23, + outputGate: "PASS", + sourceVersion: "source-42", + observedAt: DateTimeOffset.Parse("2026-06-26T10:00:00+09:00")); + + Assert.Equal(1, appendCount); + + var rows = await reader.ReadAsync("factor_output_history", 10); + Assert.Single(rows); + Assert.Equal("RS_VERDICT_V2", rows[0]["factor_id"]); + Assert.Equal("2026-06-26", rows[0]["factor_version"]); + Assert.Equal(1.23, rows[0]["output_value"]); + Assert.Equal("PASS", rows[0]["output_gate"]); + Assert.Equal("source-42", rows[0]["source_version"]); + } + + private sealed class FakeHistoryStore : IPostgresqlHistoryStore + { + private readonly Dictionary>> _rows = new(); + + public Task AppendAsync(string domain, IDictionary payload) + { + if (!_rows.TryGetValue(domain, out var list)) + { + list = new List>(); + _rows[domain] = list; + } + + list.Add(new Dictionary(payload)); + return Task.FromResult(1); + } + + public Task>> SnapshotAsync(string domain, int limit = 500) + { + if (!_rows.TryGetValue(domain, out var list)) + { + return Task.FromResult>>(Array.Empty>()); + } + + return Task.FromResult>>(list.Take(limit).ToList()); + } + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs b/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs new file mode 100644 index 0000000..abdcd5b --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs @@ -0,0 +1,41 @@ +using QuantEngine.Infrastructure.Repositories; + +namespace QuantEngine.Core.Tests; + +public class PostgresqlHistoryStoreTests +{ + [Fact] + public void DomainColumnsExposeCanonicalDomains() + { + var domains = PostgresqlHistoryStore.GetDomainColumns(); + + Assert.Contains("decision_result_history", domains.Keys); + Assert.Contains("factor_output_history", domains.Keys); + Assert.Contains("market_raw_history", domains.Keys); + Assert.Contains("market_vs_engine_gap_history", domains.Keys); + Assert.True(domains["decision_result_history"].Contains("decision_id")); + Assert.True(domains["factor_output_history"].Contains("output_gate")); + } + + [Fact] + public void BuildInsertSqlUsesEngineHistoryPrefixAndNamedParameters() + { + var sql = PostgresqlHistoryStore.BuildInsertSql( + "decision_result_history", + new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version", "provenance" }); + + Assert.Equal( + "INSERT INTO engine_history.decision_result_history (decision_id, decided_at, instrument_id, action, gate, score, source_version, provenance) VALUES (@decision_id, @decided_at, @instrument_id, @action, @gate, @score, @source_version, @provenance)", + sql); + } + + [Fact] + public void BuildSnapshotSqlUsesCreatedAtDescendingAndLimitParameter() + { + var sql = PostgresqlHistoryStore.BuildSnapshotSql("factor_output_history", 25); + + Assert.Equal( + "SELECT * FROM engine_history.factor_output_history ORDER BY created_at DESC LIMIT @Limit", + sql); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj index acbcfa6..b3c8a55 100644 --- a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj +++ b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj @@ -20,6 +20,8 @@ + + - \ No newline at end of file + diff --git a/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs b/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs new file mode 100644 index 0000000..d88f940 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using QuantEngine.Infrastructure.External; + +namespace QuantEngine.Core.Tests; + +public class SecurityTests +{ + [Theory] + [InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")] + [InlineData("/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900")] + [InlineData("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100")] + public void AssertReadOnly_AllowsReadOnlyQuotationPaths(string path, string trId) + { + var client = CreateClient(); + + var ex = Record.Exception(() => InvokeAssertReadOnly(client, path, trId)); + + Assert.Null(ex); + } + + [Theory] + [InlineData("/uapi/domestic-stock/v1/trading/order-cash", "VTTC0802U")] + [InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "TTTC084000")] + [InlineData("/uapi/domestic-stock/v1/trading/order-cash", "FHKST01010100")] + public void AssertReadOnly_BlocksTradingPathsOrIds(string path, string trId) + { + var client = CreateClient(); + + var ex = Assert.Throws(() => InvokeAssertReadOnly(client, path, trId)); + Assert.IsType(ex.InnerException); + Assert.Contains("BLOCKED", ex.InnerException!.Message); + } + + [Fact] + public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes() + { + var client = CreateClient(); + + var ex = Assert.Throws(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00")); + Assert.IsType(ex.InnerException); + Assert.Contains("TR_ID", ex.InnerException!.Message); + } + + private static KisApiClient CreateClient() + { + Environment.SetEnvironmentVariable("KIS_APP_Key_TEST", "mock-key"); + Environment.SetEnvironmentVariable("KIS_APP_Secret_TEST", "mock-secret"); + return new KisApiClient(new HttpClient(new DummyHandler()), new NoopConnectionFactory()); + } + + private static void InvokeAssertReadOnly(KisApiClient client, string path, string trId) + { + var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("AssertReadOnly method not found."); + method.Invoke(client, new object[] { path, trId }); + } + + private sealed class DummyHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + + private sealed class NoopConnectionFactory : QuantEngine.Infrastructure.Data.IDbConnectionFactory + { + public System.Data.IDbConnection CreateConnection() => throw new NotSupportedException("Not needed for read-only guard tests."); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs b/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs index 90e9c34..ce510e0 100644 --- a/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs +++ b/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs @@ -1,10 +1,85 @@ -namespace QuantEngine.Core.Tests; +namespace QuantEngine.Core.Tests; public class UnitTest1 { [Fact] - public void Test1() + public void OperationalReportLoader_ParsesCanonicalTempReport() { + var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json")); + var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path); + Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion); + Assert.Equal("GatherTradingData.json", report.SourceJson); + Assert.Equal(38, report.SectionCount); + Assert.True(report.Sections.Count >= 4); + Assert.Equal("exec_safety_declaration", report.Sections[0].Name); + Assert.Contains("source: .NET operational report builder", report.Sections[0].Preview); + } + + [Fact] + public void OperationalReportLoader_ReturnsSafeDefaultsWhenFileIsMissing() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "operational_report.json"); + var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path); + + Assert.Equal("n/a", report.SchemaVersion); + Assert.Equal("n/a", report.SourceJson); + Assert.Equal("n/a", report.GeneratedAt); + Assert.Equal(0, report.SectionCount); + Assert.Empty(report.Sections); + } + + [Fact] + public void OperationalReportLoader_UsesSectionCountFromPayloadWhenPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var path = Path.Combine(tempDir, "operational_report.json"); + + File.WriteAllText(path, """ + { + "schema_version": "test-schema", + "source_json": "fixture.json", + "generated_at": "2026-06-26T00:00:00+00:00", + "section_count": 2, + "sections": [ + { "name": "alpha", "title": "Alpha", "markdown": "alpha body" }, + { "name": "beta", "title": "Beta", "markdown": "beta body" } + ] + } + """); + + var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path); + + Assert.Equal("test-schema", report.SchemaVersion); + Assert.Equal("fixture.json", report.SourceJson); + Assert.Equal(2, report.SectionCount); + Assert.Equal(2, report.Sections.Count); + Assert.Equal("alpha", report.Sections[0].Name); + Assert.Equal("alpha body", report.Sections[0].Preview); + } + + [Fact] + public void OperationalReportLoader_PreservesEmptySectionsWithoutThrowing() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var path = Path.Combine(tempDir, "operational_report.json"); + + File.WriteAllText(path, """ + { + "schema_version": "empty-schema", + "source_json": "fixture.json", + "generated_at": "2026-06-26T00:00:00+00:00", + "section_count": 0, + "sections": [] + } + """); + + var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path); + + Assert.Equal(0, report.SectionCount); + Assert.Empty(report.Sections); + Assert.Equal("empty-schema", report.SchemaVersion); } } diff --git a/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs b/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs new file mode 100644 index 0000000..9e65fda --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace QuantEngine.Core.Infrastructure +{ + public sealed class OperationalReportData + { + public string SchemaVersion { get; init; } = "n/a"; + public string SourceJson { get; init; } = "n/a"; + public string GeneratedAt { get; init; } = "n/a"; + public int SectionCount { get; init; } + public List Sections { get; init; } = new(); + } + + public sealed record OperationalReportSection(string Name, string Title, string Preview); + + public static class OperationalReportLoader + { + public static OperationalReportData Load(string path) + { + if (!File.Exists(path)) + { + return new OperationalReportData(); + } + + using var stream = File.OpenRead(path); + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + var sections = new List(); + if (root.TryGetProperty("sections", out var sectionArray) && sectionArray.ValueKind == JsonValueKind.Array) + { + foreach (var section in sectionArray.EnumerateArray()) + { + var name = section.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty; + var title = section.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? string.Empty : string.Empty; + var markdown = section.TryGetProperty("markdown", out var markdownProp) ? markdownProp.GetString() ?? string.Empty : string.Empty; + sections.Add(new OperationalReportSection(name, title, Preview(markdown))); + } + } + + return new OperationalReportData + { + SchemaVersion = root.TryGetProperty("schema_version", out var schema) ? schema.GetString() ?? "n/a" : "n/a", + SourceJson = root.TryGetProperty("source_json", out var sourceJson) ? sourceJson.GetString() ?? "n/a" : "n/a", + GeneratedAt = root.TryGetProperty("generated_at", out var generatedAt) ? generatedAt.GetString() ?? "n/a" : "n/a", + SectionCount = root.TryGetProperty("section_count", out var count) ? count.GetInt32() : sections.Count, + Sections = sections + }; + } + + private static string Preview(string markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + return "n/a"; + } + + var trimmed = markdown.Replace("\r", " ").Replace("\n", " ").Trim(); + return trimmed.Length <= 80 ? trimmed : trimmed[..80] + "..."; + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs b/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..bd7f4ff --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")] diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs index 155f299..3a2441c 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs @@ -24,6 +24,14 @@ namespace QuantEngine.Infrastructure.Repositories _connectionFactory = connectionFactory; } + internal static IReadOnlyDictionary GetDomainColumns() => DomainColumns; + + internal static string BuildInsertSql(string domain, IReadOnlyList insertColumns) + => $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", insertColumns.Select(column => $"@{column}"))})"; + + internal static string BuildSnapshotSql(string domain, int limit) + => $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit"; + public async Task AppendAsync(string domain, IDictionary payload) { if (!DomainColumns.TryGetValue(domain, out var columns)) @@ -34,20 +42,17 @@ namespace QuantEngine.Infrastructure.Repositories var values = new DynamicParameters(); var insertColumns = new List(columns.Length + 1); - var placeholders = new List(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(); 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)})"; + var sql = BuildInsertSql(domain, insertColumns); return await conn.ExecuteAsync(sql, values); } @@ -59,7 +64,7 @@ namespace QuantEngine.Infrastructure.Repositories using var conn = _connectionFactory.CreateConnection(); conn.Open(); - var sql = $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit"; + var sql = BuildSnapshotSql(domain, limit); var rows = await conn.QueryAsync(sql, new { Limit = limit }); return rows.Select(row => (IDictionary)row).ToList(); }