core services and tests
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
using QuantEngine.Core.Models;
|
||||||
|
|
||||||
|
namespace QuantEngine.Application.Services
|
||||||
|
{
|
||||||
|
public class CollectionService
|
||||||
|
{
|
||||||
|
private readonly IPostgresqlHistoryStore _historyStore;
|
||||||
|
|
||||||
|
public CollectionService(IPostgresqlHistoryStore historyStore)
|
||||||
|
{
|
||||||
|
_historyStore = historyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> AppendRunAsync(CollectionRun run)
|
||||||
|
=> _historyStore.AppendAsync("collection_run_history", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["run_id"] = run.RunId,
|
||||||
|
["collector_name"] = run.CollectorName,
|
||||||
|
["started_at"] = run.StartedAt,
|
||||||
|
["finished_at"] = run.FinishedAt,
|
||||||
|
["status"] = run.Status,
|
||||||
|
["input_source"] = run.InputSource,
|
||||||
|
["output_json_path"] = run.OutputJsonPath,
|
||||||
|
["output_db_path"] = run.OutputDbPath,
|
||||||
|
["notes"] = run.Notes,
|
||||||
|
["created_at"] = run.CreatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<int> AppendSnapshotAsync(CollectionSnapshot snapshot)
|
||||||
|
=> _historyStore.AppendAsync("collection_snapshot_history", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["run_id"] = snapshot.RunId,
|
||||||
|
["dataset_name"] = snapshot.DatasetName,
|
||||||
|
["ticker"] = snapshot.Ticker,
|
||||||
|
["name"] = snapshot.Name,
|
||||||
|
["sector"] = snapshot.Sector,
|
||||||
|
["as_of_date"] = snapshot.AsOfDate,
|
||||||
|
["source_priority"] = snapshot.SourcePriority,
|
||||||
|
["source_status"] = snapshot.SourceStatus,
|
||||||
|
["payload_json"] = snapshot.PayloadJson,
|
||||||
|
["provenance_json"] = snapshot.ProvenanceJson,
|
||||||
|
["created_at"] = snapshot.CreatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<int> AppendSourceErrorAsync(CollectionSourceError error)
|
||||||
|
=> _historyStore.AppendAsync("collection_source_error_history", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["run_id"] = error.RunId,
|
||||||
|
["ticker"] = error.Ticker,
|
||||||
|
["source_name"] = error.SourceName,
|
||||||
|
["error_kind"] = error.ErrorKind,
|
||||||
|
["error_message"] = error.ErrorMessage,
|
||||||
|
["payload_json"] = error.PayloadJson,
|
||||||
|
["created_at"] = error.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using QuantEngine.Core.Domain;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Application.Services
|
||||||
|
{
|
||||||
|
public class FormulaService
|
||||||
|
{
|
||||||
|
private readonly IPostgresqlHistoryStore _historyStore;
|
||||||
|
|
||||||
|
public FormulaService(IPostgresqlHistoryStore historyStore)
|
||||||
|
{
|
||||||
|
_historyStore = historyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
|
||||||
|
=> FormulaEngine.ComputeTimingDecision(ctx);
|
||||||
|
|
||||||
|
public SellDecisionResult ComputeSellDecision(Dictionary<string, object> ctx)
|
||||||
|
=> FormulaEngine.ComputeSellDecision(ctx);
|
||||||
|
|
||||||
|
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
|
||||||
|
=> FormulaEngine.ComputeFinalDecision(ctx);
|
||||||
|
|
||||||
|
public CashShortfallResult ComputeCashShortfallHarness(
|
||||||
|
Dictionary<string, object> asResult,
|
||||||
|
double totalAsset,
|
||||||
|
Dictionary<string, object> cashFloorInfo,
|
||||||
|
double mrsScore)
|
||||||
|
=> FormulaEngine.ComputeCashShortfallHarness(asResult, totalAsset, cashFloorInfo, mrsScore);
|
||||||
|
|
||||||
|
public CashRecoveryPlanResult ComputeCashRecoveryOptimizer(
|
||||||
|
List<Dictionary<string, object>> sellCandidates,
|
||||||
|
double cashShortfallMinKrw)
|
||||||
|
=> FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw);
|
||||||
|
|
||||||
|
public Task<int> AppendFormulaRunAsync(string formulaName, Dictionary<string, object?> payload)
|
||||||
|
=> _historyStore.AppendAsync($"formula_{formulaName}_history", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using QuantEngine.Application.Services;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
using QuantEngine.Core.Models;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class ApplicationServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkspaceService_ForwardsSettingAndHistoryOperations()
|
||||||
|
{
|
||||||
|
var repo = new FakeWorkspaceRepository();
|
||||||
|
var history = new FakeHistoryStore();
|
||||||
|
var service = new WorkspaceService(repo, history);
|
||||||
|
|
||||||
|
var setting = new Setting { Ordinal = 1, Key = "risk_mode", ValueJson = "\"RISK_ON\"" };
|
||||||
|
Assert.True(await service.UpsertSettingAsync(setting));
|
||||||
|
Assert.Equal(setting, repo.LastSetting);
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?> { ["foo"] = "bar" };
|
||||||
|
Assert.Equal(1, await service.AppendHistoryAsync("decision_result_history", payload));
|
||||||
|
Assert.Equal("decision_result_history", history.LastDomain);
|
||||||
|
Assert.Equal("bar", history.LastPayload?["foo"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApprovalService_ForwardsApprovalAndLockOperations()
|
||||||
|
{
|
||||||
|
var repo = new FakeWorkspaceRepository();
|
||||||
|
var service = new ApprovalService(repo);
|
||||||
|
|
||||||
|
var approval = new WorkspaceApproval { Domain = "settings", TargetRef = "portfolio", Status = "APPROVED" };
|
||||||
|
Assert.True(await service.UpsertApprovalAsync(approval));
|
||||||
|
Assert.Equal(approval, repo.LastApproval);
|
||||||
|
|
||||||
|
var lockRow = new WorkspaceLock { Domain = "settings", TargetRef = "portfolio", LockedBy = "qa", Reason = "review" };
|
||||||
|
Assert.True(await service.AcquireLockAsync(lockRow));
|
||||||
|
Assert.Equal(lockRow, repo.LastLock);
|
||||||
|
Assert.True(await service.ReleaseLockAsync("settings", "portfolio"));
|
||||||
|
Assert.Equal(("settings", "portfolio"), repo.LastReleasedLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectionService_AppendsRunSnapshotAndErrorRecords()
|
||||||
|
{
|
||||||
|
var history = new FakeHistoryStore();
|
||||||
|
var service = new CollectionService(history);
|
||||||
|
|
||||||
|
await service.AppendRunAsync(new CollectionRun
|
||||||
|
{
|
||||||
|
RunId = "run-1",
|
||||||
|
CollectorName = "kis",
|
||||||
|
StartedAt = "2026-06-26T09:00:00+09:00",
|
||||||
|
Status = "PASS"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("collection_run_history", history.LastDomain);
|
||||||
|
Assert.Equal("run-1", history.LastPayload?["run_id"]);
|
||||||
|
|
||||||
|
await service.AppendSnapshotAsync(new CollectionSnapshot
|
||||||
|
{
|
||||||
|
RunId = "run-1",
|
||||||
|
DatasetName = "decision_result_history",
|
||||||
|
Ticker = "005930",
|
||||||
|
SourcePriority = "KIS",
|
||||||
|
SourceStatus = "PASS",
|
||||||
|
PayloadJson = "{}",
|
||||||
|
ProvenanceJson = "{}"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("collection_snapshot_history", history.LastDomain);
|
||||||
|
Assert.Equal("005930", history.LastPayload?["ticker"]);
|
||||||
|
|
||||||
|
await service.AppendSourceErrorAsync(new CollectionSourceError
|
||||||
|
{
|
||||||
|
RunId = "run-1",
|
||||||
|
SourceName = "naver",
|
||||||
|
ErrorKind = "TIMEOUT",
|
||||||
|
ErrorMessage = "timeout"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("collection_source_error_history", history.LastDomain);
|
||||||
|
Assert.Equal("TIMEOUT", history.LastPayload?["error_kind"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FormulaService_ForwardsFormulaExecutionAndHistory()
|
||||||
|
{
|
||||||
|
var history = new FakeHistoryStore();
|
||||||
|
var service = new FormulaService(history);
|
||||||
|
|
||||||
|
var timing = service.ComputeTimingDecision(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["entryModeGate"] = "PASS",
|
||||||
|
["entryMode"] = "BREAKOUT",
|
||||||
|
["leaderGate"] = "PASS",
|
||||||
|
["acGate"] = "CLEAR",
|
||||||
|
["priceStatus"] = "PRICE_OK",
|
||||||
|
["atr20"] = 1.0,
|
||||||
|
["leaderTotal"] = 4,
|
||||||
|
["flowCredit"] = 0.7,
|
||||||
|
["avgTradeValue5D"] = 100,
|
||||||
|
["spreadPct"] = 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEqual(string.Empty, timing.Action);
|
||||||
|
|
||||||
|
await service.AppendFormulaRunAsync("timing", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["action"] = timing.Action,
|
||||||
|
["entry_score"] = timing.EntryScore
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("formula_timing_history", history.LastDomain);
|
||||||
|
Assert.Equal(timing.Action, history.LastPayload?["action"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkspaceRepository : IWorkspaceRepository
|
||||||
|
{
|
||||||
|
public Setting? LastSetting { get; private set; }
|
||||||
|
public WorkspaceApproval? LastApproval { get; private set; }
|
||||||
|
public WorkspaceLock? LastLock { get; private set; }
|
||||||
|
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
||||||
|
|
||||||
|
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
||||||
|
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
||||||
|
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
||||||
|
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => Task.FromResult(Enumerable.Empty<AccountSnapshot>());
|
||||||
|
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => Task.FromResult(true);
|
||||||
|
public Task<bool> ClearAccountSnapshotsAsync() => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<IEnumerable<WorkspaceApproval>> GetApprovalsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceApproval>());
|
||||||
|
public Task<WorkspaceApproval?> GetApprovalAsync(string domain, string targetRef) => Task.FromResult<WorkspaceApproval?>(null);
|
||||||
|
public Task<bool> UpsertApprovalAsync(WorkspaceApproval approval) { LastApproval = approval; return Task.FromResult(true); }
|
||||||
|
|
||||||
|
public Task<IEnumerable<WorkspaceLock>> GetLocksAsync() => Task.FromResult(Enumerable.Empty<WorkspaceLock>());
|
||||||
|
public Task<WorkspaceLock?> GetLockAsync(string domain, string targetRef) => Task.FromResult<WorkspaceLock?>(null);
|
||||||
|
public Task<bool> AcquireLockAsync(WorkspaceLock @lock) { LastLock = @lock; return Task.FromResult(true); }
|
||||||
|
public Task<bool> ReleaseLockAsync(string domain, string targetRef) { LastReleasedLock = (domain, targetRef); return Task.FromResult(true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHistoryStore : IPostgresqlHistoryStore
|
||||||
|
{
|
||||||
|
public string? LastDomain { get; private set; }
|
||||||
|
public IDictionary<string, object?>? LastPayload { get; private set; }
|
||||||
|
|
||||||
|
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
|
||||||
|
{
|
||||||
|
LastDomain = domain;
|
||||||
|
LastPayload = new Dictionary<string, object?>(payload);
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
|
||||||
|
=> Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,4 +30,62 @@ public class FormulaEngineTests
|
|||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action);
|
Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeSellDecisionProducesExitTrimWhenRiskWindowIsOpen()
|
||||||
|
{
|
||||||
|
var ctx = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "close", 100.0 },
|
||||||
|
{ "profitPct", 31.0 },
|
||||||
|
{ "tp1Price", 108.0 },
|
||||||
|
{ "tp2Price", 112.0 },
|
||||||
|
{ "timingAction", "BUY_STAGE1_READY" },
|
||||||
|
{ "atr20", 4.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = FormulaEngine.ComputeSellDecision(ctx);
|
||||||
|
|
||||||
|
Assert.Equal("PROFIT_TRIM_35", result.Action);
|
||||||
|
Assert.Equal(35, result.RatioPct);
|
||||||
|
Assert.Equal("SIGNAL_CONFIRMED", result.Validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeFinalDecisionPromotesSellReadyWhenSellSignalIsConfirmed()
|
||||||
|
{
|
||||||
|
var ctx = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "sellAction", "TRIM_35" },
|
||||||
|
{ "sellValidation", "SIGNAL_CONFIRMED" },
|
||||||
|
{ "timingScoreEntry", 72.0 },
|
||||||
|
{ "timingScoreExit", 15.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = FormulaEngine.ComputeFinalDecision(ctx);
|
||||||
|
|
||||||
|
Assert.Equal("SELL_READY", result.FinalAction);
|
||||||
|
Assert.Equal(10, result.ActionPriority);
|
||||||
|
Assert.Equal("RULE_ENGINE", result.DecisionSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeCashShortfallHarnessCalculatesTargetAndShortfall()
|
||||||
|
{
|
||||||
|
var asResult = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "settlementCashD2Krw", 10_000_000.0 }
|
||||||
|
};
|
||||||
|
var cashFloor = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "minPct", 15.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = FormulaEngine.ComputeCashShortfallHarness(asResult, 100_000_000.0, cashFloor, 6.0);
|
||||||
|
|
||||||
|
Assert.Equal(10.0, result.CashCurrentPctD2);
|
||||||
|
Assert.Equal(15.0, result.CashTargetPct);
|
||||||
|
Assert.Equal(5_000_000.0, result.CashShortfallMinKrw);
|
||||||
|
Assert.Equal(5_000_000.0, result.CashShortfallTargetKrw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using QuantEngine.Application.Services;
|
||||||
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class HistoryIngestionE2ETests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendDecisionThenReadSnapshotRoundTripsThroughApplicationFlow()
|
||||||
|
{
|
||||||
|
var store = new FakeHistoryStore();
|
||||||
|
var ingestion = new HistoryIngestionService(store);
|
||||||
|
var reader = new PostgresqlHistorySnapshotReader(store);
|
||||||
|
|
||||||
|
var appendCount = await ingestion.AppendDecisionAsync(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["decision_id"] = "dec-001",
|
||||||
|
["decided_at"] = DateTimeOffset.Parse("2026-06-26T09:00:00+09:00"),
|
||||||
|
["instrument_id"] = "005930",
|
||||||
|
["action"] = "BUY",
|
||||||
|
["gate"] = "PASS",
|
||||||
|
["score"] = 87.5,
|
||||||
|
["source_version"] = "v1",
|
||||||
|
["provenance"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = "unit-test"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, appendCount);
|
||||||
|
|
||||||
|
var rows = await reader.ReadAsync("decision_result_history", 10);
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal("dec-001", rows[0]["decision_id"]);
|
||||||
|
Assert.Equal("005930", rows[0]["instrument_id"]);
|
||||||
|
Assert.Equal("BUY", rows[0]["action"]);
|
||||||
|
Assert.Equal("PASS", rows[0]["gate"]);
|
||||||
|
Assert.Equal(87.5, rows[0]["score"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendFactorOutputThenReadSnapshotPreservesPayload()
|
||||||
|
{
|
||||||
|
var store = new FakeHistoryStore();
|
||||||
|
var ingestion = new HistoryIngestionService(store);
|
||||||
|
var reader = new PostgresqlHistorySnapshotReader(store);
|
||||||
|
|
||||||
|
var appendCount = await ingestion.AppendFactorOutputAsync(
|
||||||
|
factorId: "RS_VERDICT_V2",
|
||||||
|
factorVersion: "2026-06-26",
|
||||||
|
outputValue: 1.23,
|
||||||
|
outputGate: "PASS",
|
||||||
|
sourceVersion: "source-42",
|
||||||
|
observedAt: DateTimeOffset.Parse("2026-06-26T10:00:00+09:00"));
|
||||||
|
|
||||||
|
Assert.Equal(1, appendCount);
|
||||||
|
|
||||||
|
var rows = await reader.ReadAsync("factor_output_history", 10);
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal("RS_VERDICT_V2", rows[0]["factor_id"]);
|
||||||
|
Assert.Equal("2026-06-26", rows[0]["factor_version"]);
|
||||||
|
Assert.Equal(1.23, rows[0]["output_value"]);
|
||||||
|
Assert.Equal("PASS", rows[0]["output_gate"]);
|
||||||
|
Assert.Equal("source-42", rows[0]["source_version"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHistoryStore : IPostgresqlHistoryStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<IDictionary<string, object?>>> _rows = new();
|
||||||
|
|
||||||
|
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
|
||||||
|
{
|
||||||
|
if (!_rows.TryGetValue(domain, out var list))
|
||||||
|
{
|
||||||
|
list = new List<IDictionary<string, object?>>();
|
||||||
|
_rows[domain] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new Dictionary<string, object?>(payload));
|
||||||
|
return Task.FromResult(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
|
||||||
|
{
|
||||||
|
if (!_rows.TryGetValue(domain, out var list))
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(list.Take(limit).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using QuantEngine.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class PostgresqlHistoryStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DomainColumnsExposeCanonicalDomains()
|
||||||
|
{
|
||||||
|
var domains = PostgresqlHistoryStore.GetDomainColumns();
|
||||||
|
|
||||||
|
Assert.Contains("decision_result_history", domains.Keys);
|
||||||
|
Assert.Contains("factor_output_history", domains.Keys);
|
||||||
|
Assert.Contains("market_raw_history", domains.Keys);
|
||||||
|
Assert.Contains("market_vs_engine_gap_history", domains.Keys);
|
||||||
|
Assert.True(domains["decision_result_history"].Contains("decision_id"));
|
||||||
|
Assert.True(domains["factor_output_history"].Contains("output_gate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildInsertSqlUsesEngineHistoryPrefixAndNamedParameters()
|
||||||
|
{
|
||||||
|
var sql = PostgresqlHistoryStore.BuildInsertSql(
|
||||||
|
"decision_result_history",
|
||||||
|
new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version", "provenance" });
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"INSERT INTO engine_history.decision_result_history (decision_id, decided_at, instrument_id, action, gate, score, source_version, provenance) VALUES (@decision_id, @decided_at, @instrument_id, @action, @gate, @score, @source_version, @provenance)",
|
||||||
|
sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildSnapshotSqlUsesCreatedAtDescendingAndLimitParameter()
|
||||||
|
{
|
||||||
|
var sql = PostgresqlHistoryStore.BuildSnapshotSql("factor_output_history", 25);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"SELECT * FROM engine_history.factor_output_history ORDER BY created_at DESC LIMIT @Limit",
|
||||||
|
sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using QuantEngine.Infrastructure.External;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
|
public class SecurityTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900")]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100")]
|
||||||
|
public void AssertReadOnly_AllowsReadOnlyQuotationPaths(string path, string trId)
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => InvokeAssertReadOnly(client, path, trId));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "VTTC0802U")]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "TTTC084000")]
|
||||||
|
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "FHKST01010100")]
|
||||||
|
public void AssertReadOnly_BlocksTradingPathsOrIds(string path, string trId)
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, path, trId));
|
||||||
|
Assert.IsType<InvalidOperationException>(ex.InnerException);
|
||||||
|
Assert.Contains("BLOCKED", ex.InnerException!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00"));
|
||||||
|
Assert.IsType<InvalidOperationException>(ex.InnerException);
|
||||||
|
Assert.Contains("TR_ID", ex.InnerException!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KisApiClient CreateClient()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("KIS_APP_Key_TEST", "mock-key");
|
||||||
|
Environment.SetEnvironmentVariable("KIS_APP_Secret_TEST", "mock-secret");
|
||||||
|
return new KisApiClient(new HttpClient(new DummyHandler()), new NoopConnectionFactory());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InvokeAssertReadOnly(KisApiClient client, string path, string trId)
|
||||||
|
{
|
||||||
|
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException("AssertReadOnly method not found.");
|
||||||
|
method.Invoke(client, new object[] { path, trId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DummyHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopConnectionFactory : QuantEngine.Infrastructure.Data.IDbConnectionFactory
|
||||||
|
{
|
||||||
|
public System.Data.IDbConnection CreateConnection() => throw new NotSupportedException("Not needed for read-only guard tests.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,85 @@
|
|||||||
namespace QuantEngine.Core.Tests;
|
namespace QuantEngine.Core.Tests;
|
||||||
|
|
||||||
public class UnitTest1
|
public class UnitTest1
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Test1()
|
public void OperationalReportLoader_ParsesCanonicalTempReport()
|
||||||
{
|
{
|
||||||
|
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json"));
|
||||||
|
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||||
|
|
||||||
|
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
|
||||||
|
Assert.Equal("GatherTradingData.json", report.SourceJson);
|
||||||
|
Assert.Equal(38, report.SectionCount);
|
||||||
|
Assert.True(report.Sections.Count >= 4);
|
||||||
|
Assert.Equal("exec_safety_declaration", report.Sections[0].Name);
|
||||||
|
Assert.Contains("source: .NET operational report builder", report.Sections[0].Preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OperationalReportLoader_ReturnsSafeDefaultsWhenFileIsMissing()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "operational_report.json");
|
||||||
|
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||||
|
|
||||||
|
Assert.Equal("n/a", report.SchemaVersion);
|
||||||
|
Assert.Equal("n/a", report.SourceJson);
|
||||||
|
Assert.Equal("n/a", report.GeneratedAt);
|
||||||
|
Assert.Equal(0, report.SectionCount);
|
||||||
|
Assert.Empty(report.Sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OperationalReportLoader_UsesSectionCountFromPayloadWhenPresent()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var path = Path.Combine(tempDir, "operational_report.json");
|
||||||
|
|
||||||
|
File.WriteAllText(path, """
|
||||||
|
{
|
||||||
|
"schema_version": "test-schema",
|
||||||
|
"source_json": "fixture.json",
|
||||||
|
"generated_at": "2026-06-26T00:00:00+00:00",
|
||||||
|
"section_count": 2,
|
||||||
|
"sections": [
|
||||||
|
{ "name": "alpha", "title": "Alpha", "markdown": "alpha body" },
|
||||||
|
{ "name": "beta", "title": "Beta", "markdown": "beta body" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||||
|
|
||||||
|
Assert.Equal("test-schema", report.SchemaVersion);
|
||||||
|
Assert.Equal("fixture.json", report.SourceJson);
|
||||||
|
Assert.Equal(2, report.SectionCount);
|
||||||
|
Assert.Equal(2, report.Sections.Count);
|
||||||
|
Assert.Equal("alpha", report.Sections[0].Name);
|
||||||
|
Assert.Equal("alpha body", report.Sections[0].Preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OperationalReportLoader_PreservesEmptySectionsWithoutThrowing()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var path = Path.Combine(tempDir, "operational_report.json");
|
||||||
|
|
||||||
|
File.WriteAllText(path, """
|
||||||
|
{
|
||||||
|
"schema_version": "empty-schema",
|
||||||
|
"source_json": "fixture.json",
|
||||||
|
"generated_at": "2026-06-26T00:00:00+00:00",
|
||||||
|
"section_count": 0,
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
|
||||||
|
|
||||||
|
Assert.Equal(0, report.SectionCount);
|
||||||
|
Assert.Empty(report.Sections);
|
||||||
|
Assert.Equal("empty-schema", report.SchemaVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Infrastructure
|
||||||
|
{
|
||||||
|
public sealed class OperationalReportData
|
||||||
|
{
|
||||||
|
public string SchemaVersion { get; init; } = "n/a";
|
||||||
|
public string SourceJson { get; init; } = "n/a";
|
||||||
|
public string GeneratedAt { get; init; } = "n/a";
|
||||||
|
public int SectionCount { get; init; }
|
||||||
|
public List<OperationalReportSection> Sections { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OperationalReportSection(string Name, string Title, string Preview);
|
||||||
|
|
||||||
|
public static class OperationalReportLoader
|
||||||
|
{
|
||||||
|
public static OperationalReportData Load(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new OperationalReportData();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
using var doc = JsonDocument.Parse(stream);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var sections = new List<OperationalReportSection>();
|
||||||
|
if (root.TryGetProperty("sections", out var sectionArray) && sectionArray.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var section in sectionArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = section.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var title = section.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var markdown = section.TryGetProperty("markdown", out var markdownProp) ? markdownProp.GetString() ?? string.Empty : string.Empty;
|
||||||
|
sections.Add(new OperationalReportSection(name, title, Preview(markdown)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OperationalReportData
|
||||||
|
{
|
||||||
|
SchemaVersion = root.TryGetProperty("schema_version", out var schema) ? schema.GetString() ?? "n/a" : "n/a",
|
||||||
|
SourceJson = root.TryGetProperty("source_json", out var sourceJson) ? sourceJson.GetString() ?? "n/a" : "n/a",
|
||||||
|
GeneratedAt = root.TryGetProperty("generated_at", out var generatedAt) ? generatedAt.GetString() ?? "n/a" : "n/a",
|
||||||
|
SectionCount = root.TryGetProperty("section_count", out var count) ? count.GetInt32() : sections.Count,
|
||||||
|
Sections = sections
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Preview(string markdown)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(markdown))
|
||||||
|
{
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = markdown.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||||
|
return trimmed.Length <= 80 ? trimmed : trimmed[..80] + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")]
|
||||||
@@ -24,6 +24,14 @@ namespace QuantEngine.Infrastructure.Repositories
|
|||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyDictionary<string, string[]> GetDomainColumns() => DomainColumns;
|
||||||
|
|
||||||
|
internal static string BuildInsertSql(string domain, IReadOnlyList<string> insertColumns)
|
||||||
|
=> $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", insertColumns.Select(column => $"@{column}"))})";
|
||||||
|
|
||||||
|
internal static string BuildSnapshotSql(string domain, int limit)
|
||||||
|
=> $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit";
|
||||||
|
|
||||||
public async Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
|
public async Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
|
||||||
{
|
{
|
||||||
if (!DomainColumns.TryGetValue(domain, out var columns))
|
if (!DomainColumns.TryGetValue(domain, out var columns))
|
||||||
@@ -34,20 +42,17 @@ namespace QuantEngine.Infrastructure.Repositories
|
|||||||
|
|
||||||
var values = new DynamicParameters();
|
var values = new DynamicParameters();
|
||||||
var insertColumns = new List<string>(columns.Length + 1);
|
var insertColumns = new List<string>(columns.Length + 1);
|
||||||
var placeholders = new List<string>(columns.Length + 1);
|
|
||||||
foreach (var column in columns)
|
foreach (var column in columns)
|
||||||
{
|
{
|
||||||
insertColumns.Add(column);
|
insertColumns.Add(column);
|
||||||
placeholders.Add($"@{column}");
|
|
||||||
values.Add(column, payload.TryGetValue(column, out var value) ? value : null);
|
values.Add(column, payload.TryGetValue(column, out var value) ? value : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
insertColumns.Add("provenance");
|
insertColumns.Add("provenance");
|
||||||
placeholders.Add("@provenance");
|
|
||||||
var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>();
|
var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>();
|
||||||
values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance));
|
values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance));
|
||||||
|
|
||||||
var sql = $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", placeholders)})";
|
var sql = BuildInsertSql(domain, insertColumns);
|
||||||
return await conn.ExecuteAsync(sql, values);
|
return await conn.ExecuteAsync(sql, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ namespace QuantEngine.Infrastructure.Repositories
|
|||||||
using var conn = _connectionFactory.CreateConnection();
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
conn.Open();
|
conn.Open();
|
||||||
|
|
||||||
var sql = $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit";
|
var sql = BuildSnapshotSql(domain, limit);
|
||||||
var rows = await conn.QueryAsync(sql, new { Limit = limit });
|
var rows = await conn.QueryAsync(sql, new { Limit = limit });
|
||||||
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
|
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user