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,236 @@
|
||||
namespace KArtSell.Modules.ModelOperations.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// VS-03 DOMAIN: Market Data Ingestion Policy
|
||||
///
|
||||
/// Handles:
|
||||
/// - Price data validation (OHLCV constraints)
|
||||
/// - Duplicate detection
|
||||
/// - Data normalization
|
||||
/// - Quality score assignment
|
||||
///
|
||||
/// Pure logic, no I/O, deterministic.
|
||||
/// </summary>
|
||||
|
||||
public record DailyPrice(
|
||||
Guid PriceId,
|
||||
string Symbol,
|
||||
DateOnly TradingDate,
|
||||
decimal OpenPrice,
|
||||
decimal HighPrice,
|
||||
decimal LowPrice,
|
||||
decimal ClosePrice,
|
||||
long Volume,
|
||||
DateTime PublishedAt,
|
||||
int Revision,
|
||||
string DataSource,
|
||||
string CorrelationId);
|
||||
|
||||
public record MarketIndex(
|
||||
Guid IndexId,
|
||||
string IndexCode,
|
||||
DateOnly TradingDate,
|
||||
decimal OpenValue,
|
||||
decimal HighValue,
|
||||
decimal LowValue,
|
||||
decimal CloseValue,
|
||||
decimal? ChangePercent,
|
||||
long? IndexVolume,
|
||||
DateTime PublishedAt,
|
||||
string DataSource);
|
||||
|
||||
public record IngestionBatch(
|
||||
Guid BatchId,
|
||||
string DataSource,
|
||||
DateOnly FromDate,
|
||||
DateOnly ToDate,
|
||||
List<DailyPrice> Prices,
|
||||
List<MarketIndex> Indices,
|
||||
string CorrelationId);
|
||||
|
||||
public record ValidationResult(
|
||||
bool IsValid,
|
||||
List<string> Errors,
|
||||
int QualityScore);
|
||||
|
||||
public static class MarketDataPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate single price record
|
||||
///
|
||||
/// Rules:
|
||||
/// 1. All prices > 0
|
||||
/// 2. High >= Open, Open >= Close, Close >= Low (or reasonably close)
|
||||
/// 3. Volume >= 0
|
||||
/// 4. No future dates
|
||||
/// 5. Low <= High
|
||||
/// </summary>
|
||||
public static ValidationResult ValidatePrice(DailyPrice price, DateOnly maxDate = default)
|
||||
{
|
||||
if (maxDate == default)
|
||||
maxDate = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
var errors = new List<string>();
|
||||
var qualityScore = 100;
|
||||
|
||||
// Price checks
|
||||
if (price.OpenPrice <= 0)
|
||||
errors.Add("Open price must be > 0");
|
||||
if (price.HighPrice <= 0)
|
||||
errors.Add("High price must be > 0");
|
||||
if (price.LowPrice <= 0)
|
||||
errors.Add("Low price must be > 0");
|
||||
if (price.ClosePrice <= 0)
|
||||
errors.Add("Close price must be > 0");
|
||||
|
||||
// OHLC relationship checks
|
||||
if (price.HighPrice < price.LowPrice)
|
||||
{
|
||||
errors.Add("High must be >= Low");
|
||||
qualityScore -= 20;
|
||||
}
|
||||
|
||||
if (price.HighPrice < price.OpenPrice || price.HighPrice < price.ClosePrice)
|
||||
{
|
||||
errors.Add("High must be >= Open and Close");
|
||||
qualityScore -= 10;
|
||||
}
|
||||
|
||||
if (price.LowPrice > price.OpenPrice || price.LowPrice > price.ClosePrice)
|
||||
{
|
||||
errors.Add("Low must be <= Open and Close");
|
||||
qualityScore -= 10;
|
||||
}
|
||||
|
||||
// Volume check
|
||||
if (price.Volume < 0)
|
||||
errors.Add("Volume must be >= 0");
|
||||
|
||||
if (price.Volume == 0)
|
||||
qualityScore -= 30; // Low-volume day
|
||||
|
||||
// Date check
|
||||
if (price.TradingDate > maxDate)
|
||||
{
|
||||
errors.Add("Trading date cannot be in the future");
|
||||
qualityScore -= 50;
|
||||
}
|
||||
|
||||
// Extreme price movement check (>10% daily)
|
||||
var priceRange = (price.HighPrice - price.LowPrice) / price.ClosePrice;
|
||||
if (priceRange > 0.1m)
|
||||
{
|
||||
qualityScore -= 15; // Flag for manual review
|
||||
}
|
||||
|
||||
return new ValidationResult(
|
||||
IsValid: errors.Count == 0,
|
||||
Errors: errors,
|
||||
QualityScore: Math.Max(0, qualityScore));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect duplicate prices (same symbol, date, identical OHLCV)
|
||||
///
|
||||
/// Returns true if this price already exists with identical values
|
||||
/// </summary>
|
||||
public static bool IsDuplicate(DailyPrice candidate, List<DailyPrice> existing)
|
||||
{
|
||||
var match = existing.FirstOrDefault(e =>
|
||||
e.Symbol == candidate.Symbol &&
|
||||
e.TradingDate == candidate.TradingDate);
|
||||
|
||||
if (match == null)
|
||||
return false;
|
||||
|
||||
// Check if prices are identical (within rounding tolerance)
|
||||
return Math.Abs(match.ClosePrice - candidate.ClosePrice) < 0.01m &&
|
||||
Math.Abs(match.OpenPrice - candidate.OpenPrice) < 0.01m &&
|
||||
Math.Abs(match.HighPrice - candidate.HighPrice) < 0.01m &&
|
||||
Math.Abs(match.LowPrice - candidate.LowPrice) < 0.01m &&
|
||||
match.Volume == candidate.Volume;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize price data (handle splits, outliers, etc.)
|
||||
///
|
||||
/// Returns adjusted price or None if should be filtered
|
||||
/// </summary>
|
||||
public static DailyPrice? NormalizePrice(DailyPrice price)
|
||||
{
|
||||
// Filter if volume is suspiciously low (potential halt/error)
|
||||
if (price.Volume < 100)
|
||||
return null;
|
||||
|
||||
// Round to 2 decimals (Korean Won precision)
|
||||
var normalized = price with
|
||||
{
|
||||
OpenPrice = Math.Round(price.OpenPrice, 2),
|
||||
HighPrice = Math.Round(price.HighPrice, 2),
|
||||
LowPrice = Math.Round(price.LowPrice, 2),
|
||||
ClosePrice = Math.Round(price.ClosePrice, 2),
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate entire ingestion batch
|
||||
///
|
||||
/// Returns aggregated quality metrics and error summary
|
||||
/// </summary>
|
||||
public static (int TotalRows, int ValidRows, int InvalidRows, decimal QualityScore) ValidateBatch(IngestionBatch batch)
|
||||
{
|
||||
var totalRows = batch.Prices.Count;
|
||||
var validCount = 0;
|
||||
var invalidCount = 0;
|
||||
var totalQuality = 0;
|
||||
|
||||
foreach (var price in batch.Prices)
|
||||
{
|
||||
var result = ValidatePrice(price, batch.ToDate);
|
||||
if (result.IsValid)
|
||||
{
|
||||
validCount++;
|
||||
totalQuality += result.QualityScore;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var avgQuality = validCount > 0
|
||||
? (decimal)totalQuality / validCount
|
||||
: 0;
|
||||
|
||||
return (totalRows, validCount, invalidCount, (decimal)Math.Round(avgQuality, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify data quality issue
|
||||
///
|
||||
/// Returns whether to accept, quarantine, or reject
|
||||
/// </summary>
|
||||
public static DataQualityDecision ClassifyQualityIssue(ValidationResult result)
|
||||
{
|
||||
if (result.QualityScore >= 90)
|
||||
return DataQualityDecision.Accept;
|
||||
|
||||
if (result.QualityScore >= 70)
|
||||
return DataQualityDecision.AcceptWithWarning;
|
||||
|
||||
if (result.QualityScore >= 50)
|
||||
return DataQualityDecision.Quarantine;
|
||||
|
||||
return DataQualityDecision.Reject;
|
||||
}
|
||||
}
|
||||
|
||||
public enum DataQualityDecision
|
||||
{
|
||||
Accept,
|
||||
AcceptWithWarning,
|
||||
Quarantine,
|
||||
Reject
|
||||
}
|
||||
Reference in New Issue
Block a user