feat: Complete VS-03 DOMAIN - Market Data Ingestion (Batch 2 - 3/7)
Implements market data validation and normalization: ✅ GOV: Market data ingestion specification - KRX/OpenDart data sources - Daily scheduling (9:00 KST) - Quality SLAs (99.5% availability) ✅ DATA: PIT-compliant schema (4 tables) - daily_prices: OHLCV with versioning - indices: Market indices snapshots - companies: Master data - ingestion_jobs: Audit trail ✅ DOMAIN: Policy logic (12 tests, 12/12 PASS) - ValidatePrice: OHLC constraints, date checks - IsDuplicate: Prevent redundant entries - NormalizePrice: Rounding, filtering - ClassifyQualityIssue: Quality scoring (0-100) - ValidateBatch: Aggregate metrics AGENTS.md v16.0 compliance: ✅ Necessity: WBS Phase 2 Batch 2 ✅ Simplicity: Pure validation logic, no I/O ✅ Idempotency: By (symbol, trading_date) ✅ Safety: Immutable history with versioning ✅ Quality gates: Data quality scoring Phase 2 Progress: 1/4 Batches (VS-03 GOV+DATA+DOMAIN COMPLETE) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using KArtSell.Modules.ModelOperations.Domain;
|
||||
using Xunit;
|
||||
|
||||
namespace KArtSell.ModelOperations.UnitTests;
|
||||
|
||||
public class VS03_MarketDataPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidatePrice_ValidPrice_ReturnsPass()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var result = MarketDataPolicy.ValidatePrice(price);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.True(result.QualityScore >= 85);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePrice_NegativePrice_ReturnsFail()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: -100m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var result = MarketDataPolicy.ValidatePrice(price);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("Open price must be > 0", result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePrice_HighLowerThanLow_ReturnsFail()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 68000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var result = MarketDataPolicy.ValidatePrice(price);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("High must be >= Low", result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePrice_FutureDate_ReturnsFail()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var result = MarketDataPolicy.ValidatePrice(price);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("cannot be in the future", result.Errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePrice_ZeroVolume_LowersScore()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 0,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var result = MarketDataPolicy.ValidatePrice(price);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.QualityScore < 80); // Quality degraded but still valid
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDuplicate_IdenticalPrice_ReturnsTrue()
|
||||
{
|
||||
var price1 = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var price2 = price1 with { PriceId = Guid.NewGuid(), Revision = 2 };
|
||||
|
||||
var isDuplicate = MarketDataPolicy.IsDuplicate(price2, new List<DailyPrice> { price1 });
|
||||
|
||||
Assert.True(isDuplicate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDuplicate_DifferentSymbol_ReturnsFalse()
|
||||
{
|
||||
var price1 = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var price2 = price1 with
|
||||
{
|
||||
PriceId = Guid.NewGuid(),
|
||||
Symbol = "000660"
|
||||
};
|
||||
|
||||
var isDuplicate = MarketDataPolicy.IsDuplicate(price2, new List<DailyPrice> { price1 });
|
||||
|
||||
Assert.False(isDuplicate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePrice_ValidPrice_RoundsTo2Decimals()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000.123m,
|
||||
HighPrice: 71000.456m,
|
||||
LowPrice: 69000.789m,
|
||||
ClosePrice: 70500.999m,
|
||||
Volume: 1000000,
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var normalized = MarketDataPolicy.NormalizePrice(price);
|
||||
|
||||
Assert.NotNull(normalized);
|
||||
Assert.Equal(70000.12m, normalized!.OpenPrice);
|
||||
Assert.Equal(71000.46m, normalized.HighPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePrice_LowVolume_ReturnsNull()
|
||||
{
|
||||
var price = new DailyPrice(
|
||||
PriceId: Guid.NewGuid(),
|
||||
Symbol: "005930",
|
||||
TradingDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
OpenPrice: 70000m,
|
||||
HighPrice: 71000m,
|
||||
LowPrice: 69000m,
|
||||
ClosePrice: 70500m,
|
||||
Volume: 50, // Suspiciously low
|
||||
PublishedAt: DateTime.UtcNow,
|
||||
Revision: 1,
|
||||
DataSource: "KRX",
|
||||
CorrelationId: "test-123");
|
||||
|
||||
var normalized = MarketDataPolicy.NormalizePrice(price);
|
||||
|
||||
Assert.Null(normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyQualityIssue_HighScore_ReturnsAccept()
|
||||
{
|
||||
var result = new ValidationResult(IsValid: true, Errors: new(), QualityScore: 95);
|
||||
|
||||
var decision = MarketDataPolicy.ClassifyQualityIssue(result);
|
||||
|
||||
Assert.Equal(DataQualityDecision.Accept, decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyQualityIssue_MediumScore_ReturnsAcceptWithWarning()
|
||||
{
|
||||
var result = new ValidationResult(IsValid: true, Errors: new(), QualityScore: 75);
|
||||
|
||||
var decision = MarketDataPolicy.ClassifyQualityIssue(result);
|
||||
|
||||
Assert.Equal(DataQualityDecision.AcceptWithWarning, decision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyQualityIssue_LowScore_ReturnsQuarantine()
|
||||
{
|
||||
var result = new ValidationResult(IsValid: true, Errors: new(), QualityScore: 60);
|
||||
|
||||
var decision = MarketDataPolicy.ClassifyQualityIssue(result);
|
||||
|
||||
Assert.Equal(DataQualityDecision.Quarantine, decision);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user