feat(dotnet): add factor computation audit service
This commit is contained in:
@@ -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<PriceHistoryDailyRecord> stockBars,
|
||||
List<PriceHistoryDailyRecord> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<IPostgresqlHistoryStore>();
|
||||
storeMock.Setup(s => s.AppendAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object?>>()))
|
||||
.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<IDictionary<string, object?>>()), 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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user