7dd300f5b5
Implements AGENTS.md v16.0 Infrastructure Contract for 252+ trading-day shadow runs: Database Schema: - V0008_CreateShadowRunTable.sql: Immutable audit trail, PIT-safe queries - Indexes: (model_id, created_at), (status), (published_at) - JSONB columns for metrics/gates (flexible versioning) Services (Vertical Slice pattern): - KrxDataService: Fetch OHLCV + fees from Korea Exchange; caching (24h); retry logic - MarketCalendarService: Trading sessions with KRX holidays (2024-2026 built-in) - IKrxDataService, IMarketCalendarService interfaces (testable, mockable) Tests (7/7 passing): - KrxDataService: Fetch bars, cache hits, fee schedule - MarketCalendarService: Session window, holiday exclusion, determinism, 252-day coverage - All using xUnit IAsyncLifetime for proper resource cleanup Architecture adherence: - SOLID: Service interfaces, DI-ready, separation of concerns - Complexity: Cyclomatic < 10 per method - Idempotent: KRX caching prevents duplicate API calls; date ranges deterministic - Safety: Tested cache hit/miss, holiday logic, 252-day window validation Next Phase (When user requests): - Shadow Run API Endpoint (FastEndpoints) - Hangfire Job registration & startup integration - E2E test: trigger shadow run → job → result persisted Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
118 lines
3.9 KiB
C#
118 lines
3.9 KiB
C#
using Xunit;
|
||
using KArtSell.Modules.ModelOperations.ShadowRun.Services;
|
||
using Microsoft.Extensions.Caching.Memory;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace KArtSell.Integration.Tests;
|
||
|
||
/// <summary>
|
||
/// Tests for market calendar: trading sessions, holiday exclusion, determinism.
|
||
/// </summary>
|
||
public sealed class MarketCalendarServiceTests : IAsyncLifetime
|
||
{
|
||
private IMemoryCache _cache = null!;
|
||
private ILogger<MarketCalendarService> _logger = null!;
|
||
|
||
public Task InitializeAsync()
|
||
{
|
||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||
_logger = new NoOpLogger<MarketCalendarService>();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
public Task DisposeAsync()
|
||
{
|
||
_cache?.Dispose();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetTradingSessionsAsync_ReturnsSessionsInWindow()
|
||
{
|
||
// Arrange
|
||
var service = new MarketCalendarService(_cache, _logger);
|
||
var startDate = new DateOnly(2024, 1, 2);
|
||
var endDate = new DateOnly(2024, 1, 31);
|
||
|
||
// Act
|
||
var sessions = await service.GetTradingSessionsAsync(startDate, endDate, CancellationToken.None);
|
||
|
||
// Assert
|
||
Assert.NotEmpty(sessions);
|
||
Assert.All(sessions, session =>
|
||
{
|
||
Assert.True(session >= startDate && session <= endDate);
|
||
Assert.NotEqual(DayOfWeek.Saturday, session.DayOfWeek);
|
||
Assert.NotEqual(DayOfWeek.Sunday, session.DayOfWeek);
|
||
});
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetTradingSessionsAsync_ExcludesHolidays()
|
||
{
|
||
// Arrange
|
||
var service = new MarketCalendarService(_cache, _logger);
|
||
var startDate = new DateOnly(2024, 2, 1);
|
||
var endDate = new DateOnly(2024, 2, 15); // Includes Lunar New Year
|
||
|
||
// Act
|
||
var sessions = await service.GetTradingSessionsAsync(startDate, endDate, CancellationToken.None);
|
||
|
||
// Assert
|
||
// 2024-02-09 (Lunar New Year Eve), 2024-02-10 (Lunar New Year), 2024-02-11, 2024-02-12 should be excluded
|
||
var lunarNewYearDates = new[]
|
||
{
|
||
new DateOnly(2024, 2, 9),
|
||
new DateOnly(2024, 2, 10),
|
||
new DateOnly(2024, 2, 11),
|
||
new DateOnly(2024, 2, 12)
|
||
};
|
||
|
||
Assert.DoesNotContain(lunarNewYearDates, d => sessions.Contains(d));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetTradingSessionsAsync_IsDeterministic()
|
||
{
|
||
// Arrange
|
||
var service = new MarketCalendarService(_cache, _logger);
|
||
var startDate = new DateOnly(2024, 1, 2);
|
||
var endDate = new DateOnly(2024, 1, 31);
|
||
|
||
// Act: Call twice
|
||
var sessions1 = await service.GetTradingSessionsAsync(startDate, endDate, CancellationToken.None);
|
||
var sessions2 = await service.GetTradingSessionsAsync(startDate, endDate, CancellationToken.None);
|
||
|
||
// Assert
|
||
Assert.Equal(sessions1.Count, sessions2.Count);
|
||
for (int i = 0; i < sessions1.Count; i++)
|
||
{
|
||
Assert.Equal(sessions1[i], sessions2[i]);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetTradingSessionsAsync_Covers252DaysForAnnualWindow()
|
||
{
|
||
// Arrange
|
||
var service = new MarketCalendarService(_cache, _logger);
|
||
var startDate = new DateOnly(2024, 1, 2);
|
||
var endDate = new DateOnly(2025, 1, 1);
|
||
|
||
// Act
|
||
var sessions = await service.GetTradingSessionsAsync(startDate, endDate, CancellationToken.None);
|
||
|
||
// Assert
|
||
// Typical: 250–252 trading days per year (accounting for holidays)
|
||
Assert.InRange(sessions.Count, 245, 260);
|
||
}
|
||
|
||
private sealed class NoOpLogger<T> : ILogger<T>
|
||
{
|
||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||
public bool IsEnabled(LogLevel logLevel) => false;
|
||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||
Func<TState, Exception?, string> formatter) { }
|
||
}
|
||
}
|