Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e99c15e6a5 | |||
| 99377e9ca9 | |||
| aa61465ce0 |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
|
|
||||||
public static class ExitDecisions
|
public static class ExitDecisions
|
||||||
{
|
{
|
||||||
|
private static bool IsValidNumber(double? value)
|
||||||
|
=> value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value);
|
||||||
|
|
||||||
public static StopPriceResult ComputeStopPriceCore(
|
public static StopPriceResult ComputeStopPriceCore(
|
||||||
double? entryPrice,
|
double? entryPrice,
|
||||||
double? atr20,
|
double? atr20,
|
||||||
@@ -36,7 +39,7 @@ namespace QuantEngine.Core.Domain
|
|||||||
{
|
{
|
||||||
var result = new StopPriceResult();
|
var result = new StopPriceResult();
|
||||||
|
|
||||||
if (!entryPrice.HasValue)
|
if (!IsValidNumber(entryPrice))
|
||||||
{
|
{
|
||||||
result.StopPrice = null;
|
result.StopPrice = null;
|
||||||
result.StopPriceStatus = "NO_STOP_PRICE";
|
result.StopPriceStatus = "NO_STOP_PRICE";
|
||||||
@@ -44,38 +47,45 @@ namespace QuantEngine.Core.Domain
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!atr20.HasValue && !atrMultiplier.HasValue)
|
if (!IsValidNumber(atr20) && !IsValidNumber(atrMultiplier))
|
||||||
{
|
{
|
||||||
result.StopPrice = entryPrice.Value * 0.92;
|
result.StopPrice = entryPrice.GetValueOrDefault() * 0.92;
|
||||||
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
|
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
|
||||||
result.DataMissing.Add("atr20");
|
result.DataMissing.Add("atr20");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!atrMultiplier.HasValue && (!currentPrice.HasValue || currentPrice.Value == 0))
|
var hasCurrentPrice = IsValidNumber(currentPrice) && currentPrice!.Value != 0;
|
||||||
|
|
||||||
|
if (!IsValidNumber(atrMultiplier) && !hasCurrentPrice)
|
||||||
{
|
{
|
||||||
result.StopPrice = entryPrice.Value * 0.92;
|
result.StopPrice = entryPrice.GetValueOrDefault() * 0.92;
|
||||||
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
|
result.StopPriceStatus = "DATA_MISSING — 하네스 업데이트 필요";
|
||||||
if (!atr20.HasValue) result.DataMissing.Add("atr20");
|
if (!IsValidNumber(atr20)) result.DataMissing.Add("atr20");
|
||||||
if (!currentPrice.HasValue || currentPrice.Value == 0) result.DataMissing.Add("current_price");
|
if (!hasCurrentPrice) result.DataMissing.Add("current_price");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!atrMultiplier.HasValue)
|
if (!IsValidNumber(atrMultiplier))
|
||||||
{
|
{
|
||||||
double atr20Pct = (atr20!.Value / currentPrice!.Value) * 100;
|
var atr20Value = atr20.GetValueOrDefault();
|
||||||
|
var currentPriceValue = currentPrice.GetValueOrDefault();
|
||||||
|
double atr20Pct = (atr20Value / currentPriceValue) * 100;
|
||||||
atrMultiplier = atr20Pct >= 8 ? 2.0 : 1.5;
|
atrMultiplier = atr20Pct >= 8 ? 2.0 : 1.5;
|
||||||
result.Atr20Pct = atr20Pct;
|
result.Atr20Pct = atr20Pct;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.Atr20Pct = (currentPrice.HasValue && currentPrice.Value != 0)
|
result.Atr20Pct = hasCurrentPrice
|
||||||
? (atr20!.Value / currentPrice.Value) * 100
|
? (atr20.GetValueOrDefault() / currentPrice.GetValueOrDefault()) * 100
|
||||||
: (double?)null;
|
: (double?)null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var entryPriceValue = entryPrice.GetValueOrDefault();
|
||||||
|
var atr20FinalValue = atr20.GetValueOrDefault();
|
||||||
|
var atrMultiplierValue = atrMultiplier.GetValueOrDefault();
|
||||||
result.AtrMultiplier = atrMultiplier;
|
result.AtrMultiplier = atrMultiplier;
|
||||||
result.StopPrice = Math.Max(entryPrice.Value * 0.92, entryPrice.Value - atr20!.Value * atrMultiplier.Value);
|
result.StopPrice = Math.Max(entryPriceValue * 0.92, entryPriceValue - atr20FinalValue * atrMultiplierValue);
|
||||||
result.StopPriceStatus = "PASS";
|
result.StopPriceStatus = "PASS";
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
|
|
||||||
public static class FormulaEngine
|
public static class FormulaEngine
|
||||||
{
|
{
|
||||||
|
private static bool IsValidNumber(double? value)
|
||||||
|
=> value.HasValue && !double.IsNaN(value.Value) && !double.IsInfinity(value.Value);
|
||||||
|
|
||||||
public static TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
public static TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
||||||
{
|
{
|
||||||
var reasons = new List<string>();
|
var reasons = new List<string>();
|
||||||
@@ -98,9 +101,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
reasons.Add("entry_block");
|
reasons.Add("entry_block");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (leaderTotal.HasValue && !double.IsNaN(leaderTotal.Value) && !double.IsInfinity(leaderTotal.Value))
|
if (IsValidNumber(leaderTotal))
|
||||||
{
|
{
|
||||||
if (leaderTotal.Value >= 4)
|
if (leaderTotal!.Value >= 4)
|
||||||
{
|
{
|
||||||
entryScore += 20;
|
entryScore += 20;
|
||||||
reasons.Add("leader_scan>=4");
|
reasons.Add("leader_scan>=4");
|
||||||
@@ -116,9 +119,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
entryScore += 10;
|
entryScore += 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flowCredit.HasValue && !double.IsNaN(flowCredit.Value) && !double.IsInfinity(flowCredit.Value))
|
if (IsValidNumber(flowCredit))
|
||||||
{
|
{
|
||||||
if (flowCredit.Value >= 0.7)
|
if (flowCredit!.Value >= 0.7)
|
||||||
{
|
{
|
||||||
entryScore += 20;
|
entryScore += 20;
|
||||||
reasons.Add("flow_strong");
|
reasons.Add("flow_strong");
|
||||||
@@ -147,9 +150,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
reasons.Add("anti_climax_block");
|
reasons.Add("anti_climax_block");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ma20Slope.HasValue && !double.IsNaN(ma20Slope.Value) && !double.IsInfinity(ma20Slope.Value))
|
if (IsValidNumber(ma20Slope))
|
||||||
{
|
{
|
||||||
if (ma20Slope.Value > 0)
|
if (ma20Slope!.Value > 0)
|
||||||
{
|
{
|
||||||
entryScore += 8;
|
entryScore += 8;
|
||||||
}
|
}
|
||||||
@@ -161,9 +164,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disparity.HasValue && !double.IsNaN(disparity.Value) && !double.IsInfinity(disparity.Value))
|
if (IsValidNumber(disparity))
|
||||||
{
|
{
|
||||||
if (disparity.Value >= -5 && disparity.Value <= 4)
|
if (disparity!.Value >= -5 && disparity.Value <= 4)
|
||||||
{
|
{
|
||||||
entryScore += 10;
|
entryScore += 10;
|
||||||
}
|
}
|
||||||
@@ -185,9 +188,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rsi14.HasValue && !double.IsNaN(rsi14.Value) && !double.IsInfinity(rsi14.Value))
|
if (IsValidNumber(rsi14))
|
||||||
{
|
{
|
||||||
if (rsi14.Value >= 40 && rsi14.Value <= 65)
|
if (rsi14!.Value >= 40 && rsi14.Value <= 65)
|
||||||
{
|
{
|
||||||
entryScore += 10;
|
entryScore += 10;
|
||||||
}
|
}
|
||||||
@@ -209,7 +212,7 @@ namespace QuantEngine.Core.Domain
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avgTradeValue5D.HasValue && !double.IsNaN(avgTradeValue5D.Value) && !double.IsInfinity(avgTradeValue5D.Value) && avgTradeValue5D.Value >= 50 && (!spreadPct.HasValue || double.IsNaN(spreadPct.Value) || spreadPct.Value <= 0.8))
|
if (IsValidNumber(avgTradeValue5D) && avgTradeValue5D!.Value >= 50 && (!IsValidNumber(spreadPct) || spreadPct!.Value <= 0.8))
|
||||||
{
|
{
|
||||||
entryScore += 10;
|
entryScore += 10;
|
||||||
}
|
}
|
||||||
@@ -219,9 +222,9 @@ namespace QuantEngine.Core.Domain
|
|||||||
reasons.Add("liquidity_or_spread_fail");
|
reasons.Add("liquidity_or_spread_fail");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rwPartial.HasValue && !double.IsNaN(rwPartial.Value) && !double.IsInfinity(rwPartial.Value))
|
if (IsValidNumber(rwPartial))
|
||||||
{
|
{
|
||||||
exitScore += Math.Min(100.0, Math.Max(0.0, (int)rwPartial.Value * 25.0));
|
exitScore += Math.Min(100.0, Math.Max(0.0, (int)rwPartial!.Value * 25.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(exitSignal))
|
if (!string.IsNullOrEmpty(exitSignal))
|
||||||
@@ -230,13 +233,13 @@ namespace QuantEngine.Core.Domain
|
|||||||
exitScore += parts.Length * 10;
|
exitScore += parts.Length * 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (daysToTimeStop.HasValue && !double.IsNaN(daysToTimeStop.Value) && daysToTimeStop.Value >= 0 && daysToTimeStop.Value <= 7)
|
if (IsValidNumber(daysToTimeStop) && daysToTimeStop!.Value >= 0 && daysToTimeStop.Value <= 7)
|
||||||
{
|
{
|
||||||
exitScore += 20;
|
exitScore += 20;
|
||||||
reasons.Add("time_stop_near");
|
reasons.Add("time_stop_near");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profitPct.HasValue && !double.IsNaN(profitPct.Value) && profitPct.Value >= 10)
|
if (IsValidNumber(profitPct) && profitPct!.Value >= 10)
|
||||||
{
|
{
|
||||||
exitScore += 15;
|
exitScore += 15;
|
||||||
reasons.Add("profit_protect_zone");
|
reasons.Add("profit_protect_zone");
|
||||||
@@ -249,15 +252,15 @@ namespace QuantEngine.Core.Domain
|
|||||||
double? atr20 = GetNullableDouble(ctx, "atr20");
|
double? atr20 = GetNullableDouble(ctx, "atr20");
|
||||||
string priceStatus = GetString(ctx, "priceStatus");
|
string priceStatus = GetString(ctx, "priceStatus");
|
||||||
|
|
||||||
if (priceStatus != "PRICE_OK" || !atr20.HasValue || double.IsNaN(atr20.Value) || double.IsInfinity(atr20.Value))
|
if (priceStatus != "PRICE_OK" || !IsValidNumber(atr20))
|
||||||
{
|
{
|
||||||
action = "OBSERVE_DATA_MISSING";
|
action = "OBSERVE_DATA_MISSING";
|
||||||
}
|
}
|
||||||
else if (exitScore >= 75 || (rwPartial.HasValue && rwPartial.Value >= 4))
|
else if (exitScore >= 75 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 4))
|
||||||
{
|
{
|
||||||
action = "STOP_OR_TIME_EXIT_READY";
|
action = "STOP_OR_TIME_EXIT_READY";
|
||||||
}
|
}
|
||||||
else if (exitScore >= 50 || (rwPartial.HasValue && rwPartial.Value >= 3))
|
else if (exitScore >= 50 || (IsValidNumber(rwPartial) && rwPartial!.Value >= 3))
|
||||||
{
|
{
|
||||||
action = "EXIT_REVIEW";
|
action = "EXIT_REVIEW";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user