diff --git a/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs b/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs new file mode 100644 index 00000000..d4e9795f --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/FactorComputationService.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Application.Services; + +public sealed record FactorComputationAudit( + string Ticker, + int StockBars, + int IndexBars, + string State, + DateTimeOffset ComputedAt, + string? SourceVersion); + +public sealed class FactorComputationService +{ + private readonly HistoryIngestionService _history; + private readonly string _auditRoot; + + public FactorComputationService(HistoryIngestionService history) + { + _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); + } + + public FactorOutputs Compute( + string ticker, + List stockBars, + List indexBars, + string? sourceVersion = null) + { + var computedAt = DateTimeOffset.UtcNow; + var outputs = FactorCalculator.CalculateFactors(stockBars, indexBars); + AppendAudit(new FactorComputationAudit(ticker, stockBars.Count, indexBars.Count, "SUCCEEDED", computedAt, sourceVersion)); + return outputs; + } + + public async Task AppendFactorOutputsAsync( + string ticker, + string sourceVersion, + FactorOutputs outputs, + DateTimeOffset? observedAt = null) + { + var when = observedAt ?? DateTimeOffset.UtcNow; + await _history.AppendFactorOutputAsync("momentum_20d", sourceVersion, outputs.Momentum20D, "PASS", sourceVersion, when); + await _history.AppendFactorOutputAsync("momentum_60d", sourceVersion, outputs.Momentum60D, "PASS", sourceVersion, when); + await _history.AppendFactorOutputAsync("momentum_120d", sourceVersion, outputs.Momentum120D, "PASS", sourceVersion, when); + await _history.AppendFactorOutputAsync("atr_20pct", sourceVersion, outputs.Atr20Pct, "PASS", sourceVersion, when); + 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)); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs new file mode 100644 index 00000000..f0998ca0 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/FactorComputationServiceTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Moq; +using QuantEngine.Application.Services; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; +using Xunit; + +namespace QuantEngine.Core.Tests; + +public class FactorComputationServiceTests +{ + [Fact] + public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory() + { + var storeMock = 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 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."); + } +}