Compare commits

...

2 Commits

Author SHA1 Message Date
kjh2064 e99c15e6a5 test(dotnet): expand domain parity coverage
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 18s
Validators (Pushes and Pull Requests) / validate-core (push) Successful in 2m4s
2026-07-13 00:54:30 +09:00
kjh2064 99377e9ca9 refactor(dotnet): centralize runtime audit trail
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Has been cancelled
2026-07-13 00:52:32 +09:00
8 changed files with 142 additions and 89 deletions
@@ -0,0 +1,6 @@
namespace QuantEngine.Application.Interfaces;
public interface IRuntimeAuditTrailService
{
void Append<T>(string category, string key, T payload);
}
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using QuantEngine.Core.Domain; using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Interfaces;
namespace QuantEngine.Application.Services; namespace QuantEngine.Application.Services;
@@ -15,34 +16,12 @@ public sealed record FactorComputationAudit(
public sealed class FactorComputationService public sealed class FactorComputationService
{ {
private readonly HistoryIngestionService _history; private readonly HistoryIngestionService _history;
private readonly string _auditRoot; private readonly IRuntimeAuditTrailService _auditTrail;
public FactorComputationService(HistoryIngestionService history) public FactorComputationService(HistoryIngestionService history, IRuntimeAuditTrailService auditTrail)
{ {
_history = history; _history = history;
_auditRoot = FindRepoTempRoot(); _auditTrail = auditTrail;
}
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( public FactorOutputs Compute(
@@ -53,7 +32,7 @@ public sealed class FactorComputationService
{ {
var computedAt = DateTimeOffset.UtcNow; var computedAt = DateTimeOffset.UtcNow;
var outputs = FactorCalculator.CalculateFactors(stockBars, indexBars); 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; return outputs;
} }
@@ -71,6 +50,6 @@ public sealed class FactorComputationService
await _history.AppendFactorOutputAsync("stdev_20d", sourceVersion, outputs.StDev20D, "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("beta_60d", sourceVersion, outputs.Beta60D, "PASS", sourceVersion, when);
await _history.AppendFactorOutputAsync("rs_20d", sourceVersion, outputs.Rs20D, "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));
} }
} }
@@ -17,42 +17,22 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
private readonly PriceDataNormalizer _normalizer; private readonly PriceDataNormalizer _normalizer;
private readonly SourcePriorityResolver _priorityResolver; private readonly SourcePriorityResolver _priorityResolver;
private readonly ILogger<KisDataCollectionOrchestrator> _logger; private readonly ILogger<KisDataCollectionOrchestrator> _logger;
private readonly string _auditRoot; private readonly IRuntimeAuditTrailService _auditTrail;
public KisDataCollectionOrchestrator( public KisDataCollectionOrchestrator(
IKisApiClient kisApiClient, IKisApiClient kisApiClient,
ICollectionRepository repository, ICollectionRepository repository,
PriceDataNormalizer normalizer, PriceDataNormalizer normalizer,
SourcePriorityResolver priorityResolver, SourcePriorityResolver priorityResolver,
ILogger<KisDataCollectionOrchestrator> logger) ILogger<KisDataCollectionOrchestrator> logger,
IRuntimeAuditTrailService auditTrail)
{ {
_kisApiClient = kisApiClient; _kisApiClient = kisApiClient;
_repository = repository; _repository = repository;
_normalizer = normalizer; _normalizer = normalizer;
_priorityResolver = priorityResolver; _priorityResolver = priorityResolver;
_logger = logger; _logger = logger;
_auditRoot = FindRepoTempRoot(); _auditTrail = auditTrail;
}
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);
} }
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers) public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
@@ -70,7 +50,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
try try
{ {
_logger.LogInformation("Starting collection run {RunId}", runId); _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 kisSource = new KisApiPriceSource(_kisApiClient);
var rows = new List<Dictionary<string, object>>(); var rows = new List<Dictionary<string, object>>();
@@ -183,7 +163,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
result.SourceCounts = sourceCounts; result.SourceCounts = sourceCounts;
result.Rows = rows; result.Rows = rows;
result.Errors = errors; 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 // Save run record
await _repository.SaveRunAsync(new CollectionRunRecord( await _repository.SaveRunAsync(new CollectionRunRecord(
@@ -231,7 +211,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
result.Status = "FAILED"; result.Status = "FAILED";
result.FinishedAt = DataNormalizationHelper.KstNowIso(); result.FinishedAt = DataNormalizationHelper.KstNowIso();
result.ErrorMessage = ex.Message; 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; return result;
} }
} }
@@ -385,4 +365,3 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
} }
} }
@@ -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<T>(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");
}
}
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Moq; using Moq;
using QuantEngine.Application.Interfaces;
using QuantEngine.Application.Services; using QuantEngine.Application.Services;
using QuantEngine.Core.Domain; using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
@@ -15,39 +16,17 @@ public class FactorComputationServiceTests
public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory() public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory()
{ {
var storeMock = new Mock<IPostgresqlHistoryStore>(); var storeMock = new Mock<IPostgresqlHistoryStore>();
var auditTrailMock = new Mock<IRuntimeAuditTrailService>();
storeMock.Setup(s => s.AppendAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object?>>())) storeMock.Setup(s => s.AppendAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object?>>()))
.ReturnsAsync(1); .ReturnsAsync(1);
var history = new HistoryIngestionService(storeMock.Object); var history = new HistoryIngestionService(storeMock.Object);
var service = new FactorComputationService(history); var service = new FactorComputationService(history, auditTrailMock.Object);
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); var outputs = new FactorOutputs(1, 2, 3, 4, 5, 6, 7);
await service.AppendFactorOutputsAsync("005930", "v1", outputs); await service.AppendFactorOutputsAsync("005930", "v1", outputs);
storeMock.Verify(s => s.AppendAsync("factor_output_history", It.IsAny<IDictionary<string, object?>>()), Times.Exactly(7)); storeMock.Verify(s => s.AppendAsync("factor_output_history", It.IsAny<IDictionary<string, object?>>()), Times.Exactly(7));
Assert.True(File.Exists(auditPath)); auditTrailMock.Verify(a => a.Append("factor_audit", "005930", It.IsAny<FactorComputationAudit>()), Times.Once);
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.");
} }
} }
@@ -3,6 +3,8 @@ using Moq;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using QuantEngine.Application.Interfaces;
using QuantEngine.Application.Models;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Services; using QuantEngine.Application.Services;
@@ -13,6 +15,7 @@ public class KisDataCollectionOrchestratorTests
private readonly Mock<IKisApiClient> _kisApiClientMock; private readonly Mock<IKisApiClient> _kisApiClientMock;
private readonly Mock<ICollectionRepository> _repositoryMock; private readonly Mock<ICollectionRepository> _repositoryMock;
private readonly Mock<ILogger<KisDataCollectionOrchestrator>> _loggerMock; private readonly Mock<ILogger<KisDataCollectionOrchestrator>> _loggerMock;
private readonly Mock<IRuntimeAuditTrailService> _auditTrailMock;
private readonly PriceDataNormalizer _normalizer; private readonly PriceDataNormalizer _normalizer;
private readonly SourcePriorityResolver _priorityResolver; private readonly SourcePriorityResolver _priorityResolver;
private readonly KisDataCollectionOrchestrator _orchestrator; private readonly KisDataCollectionOrchestrator _orchestrator;
@@ -22,6 +25,7 @@ public class KisDataCollectionOrchestratorTests
_kisApiClientMock = new Mock<IKisApiClient>(); _kisApiClientMock = new Mock<IKisApiClient>();
_repositoryMock = new Mock<ICollectionRepository>(); _repositoryMock = new Mock<ICollectionRepository>();
_loggerMock = new Mock<ILogger<KisDataCollectionOrchestrator>>(); _loggerMock = new Mock<ILogger<KisDataCollectionOrchestrator>>();
_auditTrailMock = new Mock<IRuntimeAuditTrailService>();
_priorityResolver = new SourcePriorityResolver(); _priorityResolver = new SourcePriorityResolver();
_normalizer = new PriceDataNormalizer(_priorityResolver); _normalizer = new PriceDataNormalizer(_priorityResolver);
@@ -30,7 +34,8 @@ public class KisDataCollectionOrchestratorTests
_repositoryMock.Object, _repositoryMock.Object,
_normalizer, _normalizer,
_priorityResolver, _priorityResolver,
_loggerMock.Object _loggerMock.Object,
_auditTrailMock.Object
); );
} }
@@ -93,12 +98,6 @@ public class KisDataCollectionOrchestratorTests
var runId = "test-run-002"; var runId = "test-run-002";
var ticker = "005930"; var ticker = "005930";
var account = "mock"; var account = "mock";
var auditPath = Path.Combine(FindRepoRoot(), "Temp", "collection_audit", $"{runId}.jsonl");
if (File.Exists(auditPath))
{
File.Delete(auditPath);
}
_repositoryMock _repositoryMock
.Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny<int>())) .Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny<int>()))
.ReturnsAsync(new List<CollectionSnapshotRecord>()); .ReturnsAsync(new List<CollectionSnapshotRecord>());
@@ -133,8 +132,7 @@ public class KisDataCollectionOrchestratorTests
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("COMPLETED", result.Status); Assert.Equal("COMPLETED", result.Status);
Assert.Equal(1, result.SuccessCount); Assert.Equal(1, result.SuccessCount);
Assert.True(File.Exists(auditPath)); _auditTrailMock.Verify(a => a.Append("collection_audit", runId, It.IsAny<CollectionExecutionAudit>()), Times.Exactly(2));
Assert.Contains("COMPLETED", File.ReadAllText(auditPath));
_kisApiClientMock.Verify( _kisApiClientMock.Verify(
k => k.GetCurrentPriceAsync(ticker, account), k => k.GetCurrentPriceAsync(ticker, account),
@@ -78,6 +78,42 @@ namespace QuantEngine.Core.Tests.ParityTests
} }
} }
[Fact]
public void StopPriceParity_HandlesMissingEntryPrice()
{
bool success = false;
try
{
var res = ExitDecisions.ComputeStopPriceCore(null, 3000.0, 100000.0, 2.0);
Assert.Null(res.StopPrice);
Assert.Equal("NO_STOP_PRICE", res.StopPriceStatus);
Assert.Contains("entry_price", res.DataMissing);
success = true;
}
finally
{
_fixture.RegisterResult(success);
}
}
[Fact]
public void StopPriceParity_HandlesMissingAtrAndMultiplier()
{
bool success = false;
try
{
var res = ExitDecisions.ComputeStopPriceCore(100000.0, null, null, null);
Assert.Equal(92000.0, res.StopPrice);
Assert.Equal("DATA_MISSING — 하네스 업데이트 필요", res.StopPriceStatus);
Assert.Contains("atr20", res.DataMissing);
success = true;
}
finally
{
_fixture.RegisterResult(success);
}
}
[Theory] [Theory]
[InlineData("STOP_OR_TIME_EXIT_READY", 0, "RISK_ON", 0.0, false, 9999, "EXIT_100")] [InlineData("STOP_OR_TIME_EXIT_READY", 0, "RISK_ON", 0.0, false, 9999, "EXIT_100")]
[InlineData("NORMAL", 4, "RISK_ON", 0.0, false, 9999, "EXIT_100")] [InlineData("NORMAL", 4, "RISK_ON", 0.0, false, 9999, "EXIT_100")]
@@ -151,6 +187,23 @@ namespace QuantEngine.Core.Tests.ParityTests
} }
} }
[Fact]
public void HeatThresholdParity_DefaultsToBaseThreshold()
{
bool success = false;
try
{
var res = ExitDecisions.ComputeDynamicHeatThresholds("");
Assert.Equal(10.0, res.HardBlock);
Assert.Equal(7.0, res.Halve);
success = true;
}
finally
{
_fixture.RegisterResult(success);
}
}
[Theory] [Theory]
[InlineData(-5.0, "NORMAL")] [InlineData(-5.0, "NORMAL")]
[InlineData(5.0, "BREAKEVEN_RATCHET")] [InlineData(5.0, "BREAKEVEN_RATCHET")]
@@ -197,5 +250,26 @@ namespace QuantEngine.Core.Tests.ParityTests
_fixture.RegisterResult(success); _fixture.RegisterResult(success);
} }
} }
[Fact]
public void TimingDecisionParity_RejectsInvalidMarketData()
{
bool success = false;
try
{
var ctx = new Dictionary<string, object>
{
{ "priceStatus", "PRICE_MISSING" }
};
var res = FormulaEngine.ComputeTimingDecision(ctx);
Assert.Equal("OBSERVE_DATA_MISSING", res.Action);
success = true;
}
finally
{
_fixture.RegisterResult(success);
}
}
} }
} }
+1
View File
@@ -109,6 +109,7 @@ try
builder.Services.AddScoped<HistoryIngestionService>(); builder.Services.AddScoped<HistoryIngestionService>();
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>(); builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
builder.Services.AddScoped<ICollectionReadModelService, CollectionReadModelService>(); builder.Services.AddScoped<ICollectionReadModelService, CollectionReadModelService>();
builder.Services.AddSingleton<IRuntimeAuditTrailService, RuntimeAuditTrailService>();
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>(); builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>(); builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();