using QuantEngine.Core.Models; namespace QuantEngine.Application.Services; /// /// 가격 데이터 정규화 — Python _collect_one() 로직 대응 /// public class PriceDataNormalizer { private readonly SourcePriorityResolver _priorityResolver; public PriceDataNormalizer(SourcePriorityResolver priorityResolver) { _priorityResolver = priorityResolver; } public (Dictionary Normalized, Dictionary Provenance) NormalizeCollectionRow( Dictionary row, PriceSourceResult? kis, PriceSourceResult? naver, bool includeNaver = false) { var ticker = (row.GetValueOrDefault("Ticker") as string) ?? (row.GetValueOrDefault("ticker") as string) ?? ""; var name = (row.GetValueOrDefault("Name") as string) ?? (row.GetValueOrDefault("name") as string) ?? ""; var sector = (row.GetValueOrDefault("Sector") as string) ?? (row.GetValueOrDefault("sector") as string); var normalized = new Dictionary(row); var (sourcePriority, provenance) = _priorityResolver.ResolveSourcePriority( ticker, kis, naver, includeNaver: includeNaver); // KIS 데이터 병합 if (kis?.Status == "OK") { MergeSourceFields(normalized, kis, new[] { "current_price", "open", "high", "low", "volume" }); MergeSourceFields(normalized, kis, new[] { "relative_return_20d", "volume_ratio_5d", "microstructure_pressure", "short_turnover_share" }); } // Naver 폴백 if (naver?.Status == "OK" || naver?.Status == "DATA_MISSING") { // Removed // Removed NormalizedSetDefault(normalized, "naver_price_status", naver?.Status); NormalizedSetDefault(normalized, "current_price", naver?.CurrentPrice); NormalizedSetDefault(normalized, "open", naver?.Open); NormalizedSetDefault(normalized, "high", naver?.High); NormalizedSetDefault(normalized, "low", naver?.Low); NormalizedSetDefault(normalized, "volume", naver?.Volume); } // 최종 폴백 (기초 데이터) NormalizedSetDefault(normalized, "current_price", DataNormalizationHelper.CoerceFloat(row.GetValueOrDefault("current_price") ?? row.GetValueOrDefault("Current_Price") ?? row.GetValueOrDefault("close"))); NormalizedSetDefault(normalized, "open", DataNormalizationHelper.CoerceFloat(row.GetValueOrDefault("open") ?? row.GetValueOrDefault("Open"))); NormalizedSetDefault(normalized, "high", DataNormalizationHelper.CoerceFloat(row.GetValueOrDefault("high") ?? row.GetValueOrDefault("High"))); NormalizedSetDefault(normalized, "low", DataNormalizationHelper.CoerceFloat(row.GetValueOrDefault("low") ?? row.GetValueOrDefault("Low"))); NormalizedSetDefault(normalized, "volume", DataNormalizationHelper.CoerceFloat(row.GetValueOrDefault("volume") ?? row.GetValueOrDefault("Volume"))); normalized["collection_as_of"] = DataNormalizationHelper.KstNowIso(); return (normalized, provenance); } private void MergeSourceFields(Dictionary target, PriceSourceResult source, string[] keys) { foreach (var key in keys) { var value = source.GetType().GetProperty(ToPascalCase(key))?.GetValue(source); if (value != null && !string.IsNullOrEmpty(value.ToString())) target[key] = value; } } private void NormalizedSetDefault(Dictionary normalized, string key, object? value) { if (!normalized.ContainsKey(key) && value != null && !string.IsNullOrEmpty(value.ToString())) normalized[key] = value; } private string ToPascalCase(string snake) { return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(snake.Replace("_", " ")).Replace(" ", ""); } }