core services and tests

This commit is contained in:
2026-06-26 18:06:36 +09:00
parent e0508324e5
commit 1e6bf702bc
12 changed files with 675 additions and 8 deletions
@@ -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<int> AppendRunAsync(CollectionRun run)
=> _historyStore.AppendAsync("collection_run_history", new Dictionary<string, object?>
{
["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<int> AppendSnapshotAsync(CollectionSnapshot snapshot)
=> _historyStore.AppendAsync("collection_snapshot_history", new Dictionary<string, object?>
{
["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<int> AppendSourceErrorAsync(CollectionSourceError error)
=> _historyStore.AppendAsync("collection_source_error_history", new Dictionary<string, object?>
{
["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
});
}
}
@@ -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<string, object> ctx)
=> FormulaEngine.ComputeTimingDecision(ctx);
public SellDecisionResult ComputeSellDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeSellDecision(ctx);
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeFinalDecision(ctx);
public CashShortfallResult ComputeCashShortfallHarness(
Dictionary<string, object> asResult,
double totalAsset,
Dictionary<string, object> cashFloorInfo,
double mrsScore)
=> FormulaEngine.ComputeCashShortfallHarness(asResult, totalAsset, cashFloorInfo, mrsScore);
public CashRecoveryPlanResult ComputeCashRecoveryOptimizer(
List<Dictionary<string, object>> sellCandidates,
double cashShortfallMinKrw)
=> FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw);
public Task<int> AppendFormulaRunAsync(string formulaName, Dictionary<string, object?> payload)
=> _historyStore.AppendAsync($"formula_{formulaName}_history", payload);
}
}
@@ -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<string, object?> { ["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<string, object>
{
["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<string, object?>
{
["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<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => Task.FromResult(Enumerable.Empty<AccountSnapshot>());
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => Task.FromResult(true);
public Task<bool> ClearAccountSnapshotsAsync() => Task.FromResult(true);
public Task<IEnumerable<WorkspaceApproval>> GetApprovalsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceApproval>());
public Task<WorkspaceApproval?> GetApprovalAsync(string domain, string targetRef) => Task.FromResult<WorkspaceApproval?>(null);
public Task<bool> UpsertApprovalAsync(WorkspaceApproval approval) { LastApproval = approval; return Task.FromResult(true); }
public Task<IEnumerable<WorkspaceLock>> GetLocksAsync() => Task.FromResult(Enumerable.Empty<WorkspaceLock>());
public Task<WorkspaceLock?> GetLockAsync(string domain, string targetRef) => Task.FromResult<WorkspaceLock?>(null);
public Task<bool> AcquireLockAsync(WorkspaceLock @lock) { LastLock = @lock; return Task.FromResult(true); }
public Task<bool> 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<string, object?>? LastPayload { get; private set; }
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
LastDomain = domain;
LastPayload = new Dictionary<string, object?>(payload);
return Task.FromResult(1);
}
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
=> Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
}
}
@@ -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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "settlementCashD2Krw", 10_000_000.0 }
};
var cashFloor = new Dictionary<string, object>
{
{ "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);
}
}
@@ -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<string, object?>
{
["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<string, object?>
{
["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<string, List<IDictionary<string, object?>>> _rows = new();
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
if (!_rows.TryGetValue(domain, out var list))
{
list = new List<IDictionary<string, object?>>();
_rows[domain] = list;
}
list.Add(new Dictionary<string, object?>(payload));
return Task.FromResult(1);
}
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
{
if (!_rows.TryGetValue(domain, out var list))
{
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
}
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(list.Take(limit).ToList());
}
}
}
@@ -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);
}
}
@@ -20,6 +20,8 @@
<ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -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<TargetInvocationException>(() => InvokeAssertReadOnly(client, path, trId));
Assert.IsType<InvalidOperationException>(ex.InnerException);
Assert.Contains("BLOCKED", ex.InnerException!.Message);
}
[Fact]
public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes()
{
var client = CreateClient();
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00"));
Assert.IsType<InvalidOperationException>(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<HttpResponseMessage> 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.");
}
}
+77 -2
View File
@@ -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);
}
}
@@ -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<OperationalReportSection> 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<OperationalReportSection>();
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] + "...";
}
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")]
@@ -24,6 +24,14 @@ namespace QuantEngine.Infrastructure.Repositories
_connectionFactory = connectionFactory;
}
internal static IReadOnlyDictionary<string, string[]> GetDomainColumns() => DomainColumns;
internal static string BuildInsertSql(string domain, IReadOnlyList<string> 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<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
if (!DomainColumns.TryGetValue(domain, out var columns))
@@ -34,20 +42,17 @@ namespace QuantEngine.Infrastructure.Repositories
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)})";
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<string, object?>)row).ToList();
}