diff --git a/src/dotnet/QuantEngine.Application/Interfaces/IRuntimeAuditTrailService.cs b/src/dotnet/QuantEngine.Application/Interfaces/IRuntimeAuditTrailService.cs new file mode 100644 index 00000000..29b4fd1a --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Interfaces/IRuntimeAuditTrailService.cs @@ -0,0 +1,6 @@ +namespace QuantEngine.Application.Interfaces; + +public interface IRuntimeAuditTrailService +{ + void Append(string category, string key, T payload); +} diff --git a/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs b/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs index d4e9795f..b93fac1d 100644 --- a/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs +++ b/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using QuantEngine.Core.Domain; using QuantEngine.Core.Interfaces; +using QuantEngine.Application.Interfaces; namespace QuantEngine.Application.Services; @@ -15,34 +16,12 @@ public sealed record FactorComputationAudit( public sealed class FactorComputationService { private readonly HistoryIngestionService _history; - private readonly string _auditRoot; + private readonly IRuntimeAuditTrailService _auditTrail; - public FactorComputationService(HistoryIngestionService history) + public FactorComputationService(HistoryIngestionService history, IRuntimeAuditTrailService auditTrail) { _history = history; - _auditRoot = FindRepoTempRoot(); - } - - private static string FindRepoTempRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current != null) - { - if (Directory.Exists(Path.Combine(current.FullName, ".git"))) - { - return Path.Combine(current.FullName, "Temp", "factor_audit"); - } - current = current.Parent; - } - - return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "factor_audit"); - } - - private void AppendAudit(FactorComputationAudit audit) - { - Directory.CreateDirectory(_auditRoot); - var path = Path.Combine(_auditRoot, $"{audit.Ticker}.jsonl"); - File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine); + _auditTrail = auditTrail; } public FactorOutputs Compute( @@ -53,7 +32,7 @@ public sealed class FactorComputationService { var computedAt = DateTimeOffset.UtcNow; var outputs = FactorCalculator.CalculateFactors(stockBars, indexBars); - AppendAudit(new FactorComputationAudit(ticker, stockBars.Count, indexBars.Count, "SUCCEEDED", computedAt, sourceVersion)); + _auditTrail.Append("factor_audit", ticker, new FactorComputationAudit(ticker, stockBars.Count, indexBars.Count, "SUCCEEDED", computedAt, sourceVersion)); return outputs; } @@ -71,6 +50,6 @@ public sealed class FactorComputationService await _history.AppendFactorOutputAsync("stdev_20d", sourceVersion, outputs.StDev20D, "PASS", sourceVersion, when); await _history.AppendFactorOutputAsync("beta_60d", sourceVersion, outputs.Beta60D, "PASS", sourceVersion, when); await _history.AppendFactorOutputAsync("rs_20d", sourceVersion, outputs.Rs20D, "PASS", sourceVersion, when); - AppendAudit(new FactorComputationAudit(ticker, 0, 0, "PERSISTED", when, sourceVersion)); + _auditTrail.Append("factor_audit", ticker, new FactorComputationAudit(ticker, 0, 0, "PERSISTED", when, sourceVersion)); } } diff --git a/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs b/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs index de099d71..8c4c1d01 100644 --- a/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs +++ b/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs @@ -17,42 +17,22 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator private readonly PriceDataNormalizer _normalizer; private readonly SourcePriorityResolver _priorityResolver; private readonly ILogger _logger; - private readonly string _auditRoot; + private readonly IRuntimeAuditTrailService _auditTrail; public KisDataCollectionOrchestrator( IKisApiClient kisApiClient, ICollectionRepository repository, PriceDataNormalizer normalizer, SourcePriorityResolver priorityResolver, - ILogger logger) + ILogger logger, + IRuntimeAuditTrailService auditTrail) { _kisApiClient = kisApiClient; _repository = repository; _normalizer = normalizer; _priorityResolver = priorityResolver; _logger = logger; - _auditRoot = FindRepoTempRoot(); - } - - private static string FindRepoTempRoot() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current != null) - { - if (Directory.Exists(Path.Combine(current.FullName, ".git"))) - { - return Path.Combine(current.FullName, "Temp", "collection_audit"); - } - current = current.Parent; - } - return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "collection_audit"); - } - - private void AppendAudit(CollectionExecutionAudit audit) - { - Directory.CreateDirectory(_auditRoot); - var path = Path.Combine(_auditRoot, $"{audit.RunId}.jsonl"); - File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine); + _auditTrail = auditTrail; } public async Task RunCollectionAsync(string runId, string account, List tickers) @@ -70,7 +50,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator try { _logger.LogInformation("Starting collection run {RunId}", runId); - AppendAudit(new CollectionExecutionAudit(runId, "RUNNING", DateTimeOffset.UtcNow, null, 0, 0, "started")); + _auditTrail.Append("collection_audit", runId, new CollectionExecutionAudit(runId, "RUNNING", DateTimeOffset.UtcNow, null, 0, 0, "started")); var kisSource = new KisApiPriceSource(_kisApiClient); var rows = new List>(); @@ -183,7 +163,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator result.SourceCounts = sourceCounts; result.Rows = rows; result.Errors = errors; - AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(finishedAt), result.SuccessCount, result.ErrorCount, "finished")); + _auditTrail.Append("collection_audit", runId, new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(finishedAt), result.SuccessCount, result.ErrorCount, "finished")); // Save run record await _repository.SaveRunAsync(new CollectionRunRecord( @@ -231,7 +211,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator result.Status = "FAILED"; result.FinishedAt = DataNormalizationHelper.KstNowIso(); result.ErrorMessage = ex.Message; - AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(result.FinishedAt), result.SuccessCount, result.ErrorCount, ex.Message)); + _auditTrail.Append("collection_audit", runId, new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(result.FinishedAt), result.SuccessCount, result.ErrorCount, ex.Message)); return result; } } @@ -385,4 +365,3 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator } } - diff --git a/src/dotnet/QuantEngine.Application/Services/RuntimeAuditTrailService.cs b/src/dotnet/QuantEngine.Application/Services/RuntimeAuditTrailService.cs new file mode 100644 index 00000000..70c99377 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/RuntimeAuditTrailService.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using QuantEngine.Application.Interfaces; + +namespace QuantEngine.Application.Services; + +public sealed class RuntimeAuditTrailService : IRuntimeAuditTrailService +{ + private readonly string _auditRoot; + + public RuntimeAuditTrailService() + { + _auditRoot = FindRepoTempRoot(); + } + + public void Append(string category, string key, T payload) + { + var root = Path.Combine(_auditRoot, category); + Directory.CreateDirectory(root); + var path = Path.Combine(root, $"{key}.jsonl"); + File.AppendAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine); + } + + private static string FindRepoTempRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + { + return Path.Combine(current.FullName, "Temp"); + } + current = current.Parent; + } + + return Path.Combine(Directory.GetCurrentDirectory(), "Temp"); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs index f0998ca0..d536a086 100644 --- a/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Moq; +using QuantEngine.Application.Interfaces; using QuantEngine.Application.Services; using QuantEngine.Core.Domain; using QuantEngine.Core.Interfaces; @@ -15,39 +16,17 @@ public class FactorComputationServiceTests public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory() { var storeMock = new Mock(); + var auditTrailMock = new Mock(); storeMock.Setup(s => s.AppendAsync(It.IsAny(), It.IsAny>())) .ReturnsAsync(1); var history = new HistoryIngestionService(storeMock.Object); - var service = new FactorComputationService(history); - - var root = FindRepoRoot(); - var auditPath = Path.Combine(root, "Temp", "factor_audit", "005930.jsonl"); - if (File.Exists(auditPath)) - { - File.Delete(auditPath); - } + var service = new FactorComputationService(history, auditTrailMock.Object); var outputs = new FactorOutputs(1, 2, 3, 4, 5, 6, 7); await service.AppendFactorOutputsAsync("005930", "v1", outputs); storeMock.Verify(s => s.AppendAsync("factor_output_history", It.IsAny>()), Times.Exactly(7)); - Assert.True(File.Exists(auditPath)); - Assert.Contains("PERSISTED", File.ReadAllText(auditPath)); - } - - private static string FindRepoRoot() - { - var current = new DirectoryInfo(System.AppContext.BaseDirectory); - while (current != null) - { - if (Directory.Exists(Path.Combine(current.FullName, ".git"))) - { - return current.FullName; - } - current = current.Parent; - } - - throw new InvalidOperationException("Repository root not found."); + auditTrailMock.Verify(a => a.Append("factor_audit", "005930", It.IsAny()), Times.Once); } } diff --git a/src/dotnet/QuantEngine.Core.Tests/KisDataCollectionOrchestratorTests.cs b/src/dotnet/QuantEngine.Core.Tests/KisDataCollectionOrchestratorTests.cs index 2f0353a5..e919bbb5 100644 --- a/src/dotnet/QuantEngine.Core.Tests/KisDataCollectionOrchestratorTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/KisDataCollectionOrchestratorTests.cs @@ -3,6 +3,8 @@ using Moq; using System.Reflection; using System.Text.Json; using Microsoft.Extensions.Logging; +using QuantEngine.Application.Interfaces; +using QuantEngine.Application.Models; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Services; @@ -13,6 +15,7 @@ public class KisDataCollectionOrchestratorTests private readonly Mock _kisApiClientMock; private readonly Mock _repositoryMock; private readonly Mock> _loggerMock; + private readonly Mock _auditTrailMock; private readonly PriceDataNormalizer _normalizer; private readonly SourcePriorityResolver _priorityResolver; private readonly KisDataCollectionOrchestrator _orchestrator; @@ -22,6 +25,7 @@ public class KisDataCollectionOrchestratorTests _kisApiClientMock = new Mock(); _repositoryMock = new Mock(); _loggerMock = new Mock>(); + _auditTrailMock = new Mock(); _priorityResolver = new SourcePriorityResolver(); _normalizer = new PriceDataNormalizer(_priorityResolver); @@ -30,7 +34,8 @@ public class KisDataCollectionOrchestratorTests _repositoryMock.Object, _normalizer, _priorityResolver, - _loggerMock.Object + _loggerMock.Object, + _auditTrailMock.Object ); } @@ -93,12 +98,6 @@ public class KisDataCollectionOrchestratorTests var runId = "test-run-002"; var ticker = "005930"; var account = "mock"; - var auditPath = Path.Combine(FindRepoRoot(), "Temp", "collection_audit", $"{runId}.jsonl"); - if (File.Exists(auditPath)) - { - File.Delete(auditPath); - } - _repositoryMock .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny())) .ReturnsAsync(new List()); @@ -133,8 +132,7 @@ public class KisDataCollectionOrchestratorTests Assert.NotNull(result); Assert.Equal("COMPLETED", result.Status); Assert.Equal(1, result.SuccessCount); - Assert.True(File.Exists(auditPath)); - Assert.Contains("COMPLETED", File.ReadAllText(auditPath)); + _auditTrailMock.Verify(a => a.Append("collection_audit", runId, It.IsAny()), Times.Exactly(2)); _kisApiClientMock.Verify( k => k.GetCurrentPriceAsync(ticker, account), diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index 05ebf959..e81d782e 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -109,6 +109,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient();