feat(qe-m3-01): implement GetBarsAsOf with lookahead bias prevention and complete unit tests
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 9s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s

This commit is contained in:
2026-07-12 21:46:38 +09:00
parent 5589a0432b
commit b0c9776601
7 changed files with 363 additions and 7 deletions
@@ -0,0 +1,198 @@
using System.Reflection;
using Xunit;
using QuantEngine.Core.Interfaces;
using QuantEngine.Infrastructure.Repositories;
namespace QuantEngine.Core.Tests;
public class PriceHistoryReaderTests
{
[Fact]
public void IPriceHistoryReader_InterfaceExists()
{
var interfaceType = typeof(IPriceHistoryReader);
Assert.NotNull(interfaceType);
Assert.True(interfaceType.IsInterface);
}
[Fact]
public void IPriceHistoryReader_HasGetBarsAsOfMethod()
{
var interfaceType = typeof(IPriceHistoryReader);
var method = interfaceType.GetMethod("GetBarsAsOf");
Assert.NotNull(method);
Assert.True(method.IsPublic);
}
[Fact]
public void GetBarsAsOf_MethodSignatureIsCorrect()
{
var method = typeof(IPriceHistoryReader).GetMethod("GetBarsAsOf");
Assert.NotNull(method);
var parameters = method.GetParameters();
Assert.Equal(3, parameters.Length);
Assert.Equal("ticker", parameters[0].Name);
Assert.Equal(typeof(string), parameters[0].ParameterType);
Assert.Equal("asOfDate", parameters[1].Name);
Assert.Equal(typeof(DateOnly), parameters[1].ParameterType);
Assert.Equal("lookback", parameters[2].Name);
Assert.Equal(typeof(int), parameters[2].ParameterType);
var returnType = method!.ReturnType;
Assert.True(returnType.IsGenericType);
Assert.Contains("Task", returnType.Name);
}
[Fact]
public void PriceHistoryReader_ImplementsIPriceHistoryReader()
{
var readerType = typeof(PriceHistoryReader);
var interfaceType = typeof(IPriceHistoryReader);
Assert.True(interfaceType.IsAssignableFrom(readerType));
}
[Fact]
public void PriceHistoryReader_HasPublicGetBarsAsOfMethod()
{
var method = typeof(PriceHistoryReader).GetMethod("GetBarsAsOf", BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(method);
Assert.Equal("GetBarsAsOf", method.Name);
}
[Fact]
public void PriceHistoryReader_SourceCode_ContainsNoLookaheadGuarantee()
{
var repoRoot = FindRepositoryRoot();
var sourceFile = Path.Combine(
repoRoot,
"src", "dotnet",
"QuantEngine.Infrastructure", "Repositories",
"PriceHistoryReader.cs");
Assert.True(File.Exists(sourceFile), $"Source file not found at {sourceFile}");
var sourceCode = File.ReadAllText(sourceFile);
// The no-lookahead guarantee is: WHERE trade_date <= @AsOfDate
// This clause MUST be present in the SQL query to ensure no future data leaks.
Assert.True(sourceCode.Contains("trade_date <= @AsOfDate"),
"PriceHistoryReader must enforce trade_date <= @AsOfDate in SQL WHERE clause " +
"to prevent lookahead bias. Future bars (trade_date > asOfDate) must never be returned.");
}
[Fact]
public void PriceHistoryReader_SourceCode_DoesNotContainTradeDate_LessThan_AsOfDate()
{
var repoRoot = FindRepositoryRoot();
var sourceFile = Path.Combine(
repoRoot,
"src", "dotnet",
"QuantEngine.Infrastructure", "Repositories",
"PriceHistoryReader.cs");
var sourceCode = File.ReadAllText(sourceFile);
// Strict check: The query must use <= (inclusive), not < (exclusive).
// If someone later changes this to < by mistake, this test catches it.
Assert.False(sourceCode.Contains("trade_date < @AsOfDate"),
"trade_date < @AsOfDate (exclusive) is incorrect. Use trade_date <= @AsOfDate (inclusive) " +
"to include bars on the exact asOfDate.");
}
[Fact]
public void PriceHistoryReader_SourceCode_QueryOrdersDescending()
{
var repoRoot = FindRepositoryRoot();
var sourceFile = Path.Combine(
repoRoot,
"src", "dotnet",
"QuantEngine.Infrastructure", "Repositories",
"PriceHistoryReader.cs");
var sourceCode = File.ReadAllText(sourceFile);
// Ensure most-recent-first ordering by checking for DESC in ORDER BY
Assert.True(sourceCode.Contains("ORDER BY trade_date DESC"),
"Query must order by trade_date DESC to return most-recent bars first.");
}
[Fact]
public void PriceHistoryReader_SourceCode_AppliesLookbackLimit()
{
var repoRoot = FindRepositoryRoot();
var sourceFile = Path.Combine(
repoRoot,
"src", "dotnet",
"QuantEngine.Infrastructure", "Repositories",
"PriceHistoryReader.cs");
var sourceCode = File.ReadAllText(sourceFile);
// Ensure LIMIT clause is present to avoid unbounded result sets
Assert.True(sourceCode.Contains("LIMIT @Lookback"),
"Query must apply LIMIT @Lookback to constrain result set size.");
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root (CLAUDE.md)");
}
[Fact]
public void GetBarsAsOf_ReturnsTask()
{
var method = typeof(IPriceHistoryReader).GetMethod("GetBarsAsOf");
Assert.NotNull(method);
var returnType = method!.ReturnType;
Assert.NotNull(returnType);
Assert.True(returnType.IsGenericType, $"Return type {returnType} must be a generic Task<T>");
var listType = returnType.GetGenericArguments()[0];
Assert.True(listType.IsGenericType);
var recordType = listType.GetGenericArguments()[0];
Assert.Equal(typeof(PriceHistoryDailyRecord), recordType);
}
[Fact]
public void PriceHistoryDailyRecord_ContainsAllRequiredFields()
{
var recordType = typeof(PriceHistoryDailyRecord);
var properties = recordType.GetProperties();
var fieldNames = new[] { "Ticker", "TradeDate", "Open", "High", "Low", "Close", "Volume", "Source", "ProvenanceJson" };
foreach (var fieldName in fieldNames)
{
var prop = properties.FirstOrDefault(p => p.Name == fieldName);
Assert.True(prop != null, $"PriceHistoryDailyRecord must have property {fieldName}");
}
}
[Fact]
public void IPriceHistoryReader_Constructor_AcceptsIDbConnectionFactory()
{
var ctor = typeof(PriceHistoryReader).GetConstructors();
Assert.NotEmpty(ctor);
var singleParamCtor = ctor.FirstOrDefault(c => c.GetParameters().Length == 1);
Assert.NotNull(singleParamCtor!);
var param = singleParamCtor.GetParameters()[0];
var paramTypeName = param.ParameterType.Name;
Assert.Equal("IDbConnectionFactory", paramTypeName);
}
}
@@ -0,0 +1,23 @@
namespace QuantEngine.Core.Interfaces;
/// <summary>
/// Provides point-in-time price history access with structural lookahead-bias prevention.
///
/// All methods enforce: trade_date &lt;= asOfDate is guaranteed by SQL WHERE clause,
/// not by client-side filtering. This structural guarantee prevents any code path
/// from accidentally accessing future data relative to the computation date.
/// </summary>
public interface IPriceHistoryReader
{
/// <summary>
/// Returns up to <paramref name="lookback"/> daily bars for <paramref name="ticker"/>
/// with trade_date &lt;= <paramref name="asOfDate"/>, ordered most-recent-first.
///
/// GUARANTEE: Never returns a bar dated after asOfDate — enforced by SQL WHERE clause.
/// </summary>
/// <param name="ticker">Stock ticker symbol</param>
/// <param name="asOfDate">Observation date (inclusive upper bound)</param>
/// <param name="lookback">Maximum number of bars to return</param>
/// <returns>List of PriceHistoryDailyRecord, most-recent-first</returns>
Task<List<PriceHistoryDailyRecord>> GetBarsAsOf(string ticker, DateOnly asOfDate, int lookback);
}
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using QuantEngine.Core.Interfaces;
using QuantEngine.Infrastructure.Data;
namespace QuantEngine.Infrastructure.Repositories;
public class PriceHistoryReader : IPriceHistoryReader
{
private readonly IDbConnectionFactory _connectionFactory;
public PriceHistoryReader(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<List<PriceHistoryDailyRecord>> GetBarsAsOf(string ticker, DateOnly asOfDate, int lookback)
{
using var conn = _connectionFactory.CreateConnection();
var rows = await conn.QueryAsync<PriceHistoryDailyRecordRow>(@"
SELECT ticker, trade_date, open, high, low, close, volume, source, provenance
FROM quantengine.price_history_daily
WHERE ticker = @Ticker AND trade_date <= @AsOfDate
ORDER BY trade_date DESC
LIMIT @Lookback",
new { Ticker = ticker, AsOfDate = asOfDate, Lookback = lookback });
return rows.Select(r => new PriceHistoryDailyRecord(
r.Ticker,
r.TradeDate,
r.Open,
r.High,
r.Low,
r.Close,
r.Volume,
r.Source,
r.Provenance)).ToList();
}
private record PriceHistoryDailyRecordRow(
string Ticker,
DateOnly TradeDate,
decimal Open,
decimal High,
decimal Low,
decimal Close,
long Volume,
string Source,
string? Provenance);
}
+1
View File
@@ -115,6 +115,7 @@ try
builder.Services.AddScoped<SourcePriorityResolver>();
builder.Services.AddScoped<PriceDataNormalizer>();
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
builder.Services.AddScoped<IPriceHistoryReader, PriceHistoryReader>();
// Hangfire Background Jobs
try