Bläddra i källkod

feat(market): 세계지수·종목 수집 소스 Stooq→Yahoo Finance v8 전환

- Stooq 봇차단(JS PoW) 도입으로 서버측 CSV 수집 불가 → Yahoo v8 chart(무키, UA만) 전환
- YahooFinanceHttp(404=null·3재시도) + YahooChartParser(System.Text.Json, meta→OHLC/Volume, 지수·종목 공용)
- WorldIndexSyncService/MarketQuoteSyncService: per-symbol GET+parse+upsert, 심볼 실패 격리·전량실패만 재시도
- DI에 YahooFinance HttpClient(UA) 등록 + WorldIndex/MarketQuote BaseUrl 기본값 Yahoo로
- 파서 단위테스트 3(실 JSON) — 빌드 0에러, 전체 168 테스트 통과. Stooq 파일은 미사용으로 보존

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 veckor sedan
förälder
incheckning
290b86b649

+ 8 - 1
Infrastructure/DependencyInjection.cs

@@ -288,12 +288,19 @@ public static class DependencyInjection
             client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/xml"));
             client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/xml"));
         });
         });
 
 
-        // Stooq(stooq.com) 무인증 CSV 시세 클라이언트 — 세계 주요국 증시지수 스냅샷 수집 (StooqHttp). 인증 없음
+        // Stooq(stooq.com) 무인증 CSV 시세 클라이언트 — (구) 세계지수/종목 수집. Stooq 봇차단(2026-07)으로 미사용, 등록만 보존
         services.AddHttpClient(StooqHttp.ClientName, client =>
         services.AddHttpClient(StooqHttp.ClientName, client =>
         {
         {
             client.Timeout = TimeSpan.FromSeconds(30);
             client.Timeout = TimeSpan.FromSeconds(30);
         });
         });
 
 
+        // Yahoo Finance v8 chart 무인증 JSON 클라이언트 — 세계지수·종목 수집 (YahooFinanceHttp). UA 필수(없으면 거부). Stooq 대체
+        services.AddHttpClient(YahooFinanceHttp.ClientName, client =>
+        {
+            client.Timeout = TimeSpan.FromSeconds(30);
+            client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0");
+        });
+
         // SEIBro 카테고리별 일일 호출 예산 트래커 — 같은 카테고리 배치들이 하나의 카운터를 공유 (in-memory, KST 자정 롤오버)
         // SEIBro 카테고리별 일일 호출 예산 트래커 — 같은 카테고리 배치들이 하나의 카운터를 공유 (in-memory, KST 자정 롤오버)
         services.AddSingleton(_ => new SeibroQuota());
         services.AddSingleton(_ => new SeibroQuota());
 
 

+ 38 - 36
Infrastructure/StockData/MarketQuoteSyncService.cs

@@ -10,9 +10,10 @@ using SharedKernel;
 namespace Infrastructure.StockData;
 namespace Infrastructure.StockData;
 
 
 /// <summary>
 /// <summary>
-/// 주요 종목·상품 시세 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlcv, 거래량 포함)를 하루 1회 조회해 심볼당 최신 종가를 upsert.
-/// config(MarketQuote:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 MarketQuoteSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출).
-/// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST(세계지수 07:00 직후). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도.
+/// 주요 종목·상품 시세 스냅샷 수집 — Yahoo Finance v8 chart(무키)를 심볼별로 하루 1회 조회해 최신 종가·거래량을 upsert.
+/// config(MarketQuote:Items) 심볼(Yahoo 표기, 예: AAPL/MSFT/CL=F)을 하나씩 GET 하고 meta 를 파싱해 MarketQuoteSnapshot 에 반영한다.
+/// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST. 심볼 실패는 skip(격리), 전량 실패 시에만 재시도.
+/// (Stooq 봇차단으로 소스 전환 2026-07-09)
 /// </summary>
 /// </summary>
 internal sealed class MarketQuoteSyncService(
 internal sealed class MarketQuoteSyncService(
     IServiceScopeFactory scopeFactory,
     IServiceScopeFactory scopeFactory,
@@ -42,48 +43,43 @@ internal sealed class MarketQuoteSyncService(
 
 
         using var scope = scopeFactory.CreateScope();
         using var scope = scopeFactory.CreateScope();
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
-        var client = httpClientFactory.CreateClient(StooqHttp.ClientName);
-
-        var bySymbol = items
-            .GroupBy(c => c.Symbol.Trim().ToLowerInvariant())
-            .ToDictionary(g => g.Key, g => g.First());
-
-        var symbolList = string.Join(",", bySymbol.Keys);
-        var url = $"{cfg.BaseUrl.TrimEnd('/')}/q/l/?s={symbolList}&f=sd2t2ohlcv&h&e=csv";
-
-        string csv;
-        try
-        {
-            csv = await StooqHttp.GetStringWithRetryAsync(client, url, Logger, ct);
-        }
-        catch (Exception ex) when (ex is not OperationCanceledException)
-        {
-            Logger.LogError(ex, "[{Job}] Stooq 호출 실패 — 재시도 대상", JobName);
-            return false;
-        }
-
-        var quotes = MarketQuoteCsvParser.Parse(csv);
-        Logger.LogInformation("[{Job}] 응답 파싱 rows={Rows} (요청 {Count} 심볼)", JobName, quotes.Count, bySymbol.Count);
-
-        if (quotes.Count == 0)
-        {
-            Logger.LogWarning("[{Job}] 파싱 0건 — 재시도 대상", JobName);
-            return false;
-        }
+        var client = httpClientFactory.CreateClient(YahooFinanceHttp.ClientName);
 
 
         var existing = await db.MarketQuoteSnapshot.ToListAsync(ct);
         var existing = await db.MarketQuoteSnapshot.ToListAsync(ct);
         var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
         var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
+
+        var baseUrl = cfg.BaseUrl.TrimEnd('/');
         var inserted = 0;
         var inserted = 0;
         var updated = 0;
         var updated = 0;
+        var failed = 0;
 
 
-        foreach (var q in quotes)
+        foreach (var meta in items)
         {
         {
-            if (!bySymbol.TryGetValue(q.Symbol, out var meta))
+            ct.ThrowIfCancellationRequested();
+
+            var symbol = meta.Symbol.Trim();
+            var category = Enum.TryParse<QuoteCategory>(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock;
+            var url = $"{baseUrl}/v8/finance/chart/{Uri.EscapeDataString(symbol)}?interval=1d&range=5d";
+
+            YahooChartParser.YahooQuote? q;
+            try
             {
             {
-                continue; // 요청하지 않은 심볼 방어
+                var json = await YahooFinanceHttp.GetStringWithRetryAsync(client, url, Logger, ct);
+                q = YahooChartParser.Parse(json, symbol);
+            }
+            catch (Exception ex) when (ex is not OperationCanceledException)
+            {
+                Logger.LogWarning(ex, "[{Job}] {Symbol} 조회 실패 — skip", JobName, symbol);
+                failed++;
+                continue;
             }
             }
 
 
-            var category = Enum.TryParse<QuoteCategory>(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock;
+            if (q is null)
+            {
+                Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol);
+                failed++;
+                continue;
+            }
 
 
             if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
             if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
             {
             {
@@ -100,8 +96,14 @@ internal sealed class MarketQuoteSyncService(
             }
             }
         }
         }
 
 
+        if (inserted == 0 && updated == 0)
+        {
+            Logger.LogWarning("[{Job}] 전량 실패 (failed={Failed}) — 재시도 대상", JobName, failed);
+            return false;
+        }
+
         await db.SaveChangesAsync(ct);
         await db.SaveChangesAsync(ct);
-        Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}", JobName, inserted, updated);
+        Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed);
         return true;
         return true;
     }
     }
 }
 }

+ 37 - 36
Infrastructure/StockData/WorldIndexSyncService.cs

@@ -9,10 +9,10 @@ using SharedKernel;
 namespace Infrastructure.StockData;
 namespace Infrastructure.StockData;
 
 
 /// <summary>
 /// <summary>
-/// 세계 주요국 증시지수 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlc)를 하루 1회 조회해 심볼당 최신 종가를 upsert.
-/// config(WorldIndex:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 WorldIndexSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출).
+/// 세계 주요국 증시지수 스냅샷 수집 — Yahoo Finance v8 chart(무키)를 심볼별로 하루 1회 조회해 최신 종가를 upsert.
+/// config(WorldIndex:Items) 심볼(Yahoo 표기, 예: ^GSPC/^N225/000001.SS)을 하나씩 GET 하고 meta 를 파싱해 WorldIndexSnapshot 에 반영한다.
 /// 국내(코스피)는 이 배치가 아니라 GetWorldIndices Handler 가 IndexDailyPrice 실데이터에서 병합한다.
 /// 국내(코스피)는 이 배치가 아니라 GetWorldIndices Handler 가 IndexDailyPrice 실데이터에서 병합한다.
-/// 기본 07:00 KST 실행(미국장 마감 이후 확정치 확보). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도.
+/// 기본 07:00 KST 실행. 심볼 하나 실패는 skip(격리)하고, 전량 실패 시에만 재시도. (Stooq 봇차단으로 소스 전환 2026-07-09)
 /// </summary>
 /// </summary>
 internal sealed class WorldIndexSyncService(
 internal sealed class WorldIndexSyncService(
     IServiceScopeFactory scopeFactory,
     IServiceScopeFactory scopeFactory,
@@ -42,46 +42,41 @@ internal sealed class WorldIndexSyncService(
 
 
         using var scope = scopeFactory.CreateScope();
         using var scope = scopeFactory.CreateScope();
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
         var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
-        var client = httpClientFactory.CreateClient(StooqHttp.ClientName);
-
-        // 심볼별 config 매핑 (소문자 정규화 — 응답 심볼과 대소문자 무시 매칭, 중복 심볼은 첫 항목)
-        var bySymbol = items
-            .GroupBy(c => c.Symbol.Trim().ToLowerInvariant())
-            .ToDictionary(g => g.Key, g => g.First());
-
-        var symbolList = string.Join(",", bySymbol.Keys);
-        var url = $"{cfg.BaseUrl.TrimEnd('/')}/q/l/?s={symbolList}&f=sd2t2ohlc&h&e=csv";
-
-        string csv;
-        try
-        {
-            csv = await StooqHttp.GetStringWithRetryAsync(client, url, Logger, ct);
-        }
-        catch (Exception ex) when (ex is not OperationCanceledException)
-        {
-            Logger.LogError(ex, "[{Job}] Stooq 호출 실패 — 재시도 대상", JobName);
-            return false;
-        }
-
-        var quotes = WorldIndexCsvParser.Parse(csv);
-        Logger.LogInformation("[{Job}] 응답 파싱 rows={Rows} (요청 {Count} 심볼)", JobName, quotes.Count, bySymbol.Count);
-
-        if (quotes.Count == 0)
-        {
-            Logger.LogWarning("[{Job}] 파싱 0건 — 재시도 대상", JobName);
-            return false;
-        }
+        var client = httpClientFactory.CreateClient(YahooFinanceHttp.ClientName);
 
 
         var existing = await db.WorldIndexSnapshot.ToListAsync(ct);
         var existing = await db.WorldIndexSnapshot.ToListAsync(ct);
         var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
         var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
+
+        var baseUrl = cfg.BaseUrl.TrimEnd('/');
         var inserted = 0;
         var inserted = 0;
         var updated = 0;
         var updated = 0;
+        var failed = 0;
 
 
-        foreach (var q in quotes)
+        foreach (var meta in items)
         {
         {
-            if (!bySymbol.TryGetValue(q.Symbol, out var meta))
+            ct.ThrowIfCancellationRequested();
+
+            var symbol = meta.Symbol.Trim();
+            var url = $"{baseUrl}/v8/finance/chart/{Uri.EscapeDataString(symbol)}?interval=1d&range=5d";
+
+            YahooChartParser.YahooQuote? q;
+            try
+            {
+                var json = await YahooFinanceHttp.GetStringWithRetryAsync(client, url, Logger, ct);
+                q = YahooChartParser.Parse(json, symbol);
+            }
+            catch (Exception ex) when (ex is not OperationCanceledException)
+            {
+                Logger.LogWarning(ex, "[{Job}] {Symbol} 조회 실패 — skip", JobName, symbol);
+                failed++;
+                continue;
+            }
+
+            if (q is null)
             {
             {
-                continue; // 요청하지 않은 심볼 방어
+                Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol);
+                failed++;
+                continue;
             }
             }
 
 
             if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
             if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
@@ -99,8 +94,14 @@ internal sealed class WorldIndexSyncService(
             }
             }
         }
         }
 
 
+        if (inserted == 0 && updated == 0)
+        {
+            Logger.LogWarning("[{Job}] 전량 실패 (failed={Failed}) — 재시도 대상", JobName, failed);
+            return false;
+        }
+
         await db.SaveChangesAsync(ct);
         await db.SaveChangesAsync(ct);
-        Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}", JobName, inserted, updated);
+        Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed);
         return true;
         return true;
     }
     }
 }
 }

+ 92 - 0
Infrastructure/StockData/YahooChartParser.cs

@@ -0,0 +1,92 @@
+using System.Text.Json;
+
+namespace Infrastructure.StockData;
+
+/// <summary>
+/// Yahoo Finance v8 chart JSON 파서 — 순수 C#(System.Text.Json). 지수·종목 공용.
+/// chart.result[0].meta 에서 종가(regularMarketPrice)·고저(regularMarketDayHigh/Low)·거래량(regularMarketVolume)·
+/// 거래시각(regularMarketTime, gmtoffset)을 뽑는다. Open 은 meta 에 없어 종가로 대체(화면 미표시).
+/// 전일 대비는 스냅샷 엔티티가 직전 저장 종가(PrevClose)로 산출하므로 여기서 다루지 않는다(Stooq 파서와 동일 계약).
+/// </summary>
+public static class YahooChartParser
+{
+    /// <summary>시세 1행 (외부 소스 최신 종가 기준)</summary>
+    public sealed record YahooQuote(
+        string Symbol,
+        DateOnly TradeDate,
+        decimal Open,
+        decimal High,
+        decimal Low,
+        decimal Close,
+        long Volume
+    );
+
+    /// <summary>파싱 실패(빈 응답·result 없음·종가 없음·시각 없음)는 null. requestedSymbol 은 meta.symbol 결측 시 폴백.</summary>
+    public static YahooQuote? Parse(string? json, string requestedSymbol)
+    {
+        if (string.IsNullOrWhiteSpace(json))
+        {
+            return null;
+        }
+
+        try
+        {
+            using var doc = JsonDocument.Parse(json);
+
+            if (!doc.RootElement.TryGetProperty("chart", out var chart) || !chart.TryGetProperty("result", out var result))
+            {
+                return null;
+            }
+
+            if (result.ValueKind != JsonValueKind.Array || result.GetArrayLength() == 0)
+            {
+                return null;
+            }
+
+            if (!result[0].TryGetProperty("meta", out var meta))
+            {
+                return null;
+            }
+
+            if (!TryDecimal(meta, "regularMarketPrice", out var close))
+            {
+                return null;
+            }
+
+            if (!TryInt64(meta, "regularMarketTime", out var epoch) || epoch <= 0)
+            {
+                return null;
+            }
+
+            var high = TryDecimal(meta, "regularMarketDayHigh", out var h) ? h : close;
+            var low = TryDecimal(meta, "regularMarketDayLow", out var l) ? l : close;
+            var volume = TryInt64(meta, "regularMarketVolume", out var v) ? v : 0L;
+            var gmt = TryInt64(meta, "gmtoffset", out var g) ? g : 0L;
+            var open = close; // meta 에 시가 없음 — 화면 미표시라 종가로 대체
+
+            var tradeDate = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(epoch).ToOffset(TimeSpan.FromSeconds(gmt)).DateTime);
+
+            var symbol = meta.TryGetProperty("symbol", out var s) && s.ValueKind == JsonValueKind.String
+                ? s.GetString()!
+                : requestedSymbol;
+
+            return new YahooQuote(symbol.Trim().ToLowerInvariant(), tradeDate, open, high, low, close, volume);
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+    }
+
+    private static bool TryDecimal(JsonElement obj, string name, out decimal value)
+    {
+        value = 0m;
+        return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out value);
+    }
+
+    private static bool TryInt64(JsonElement obj, string name, out long value)
+    {
+        value = 0L;
+        return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out value);
+    }
+}

+ 40 - 0
Infrastructure/StockData/YahooFinanceHttp.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.StockData;
+
+/// <summary>
+/// Yahoo Finance v8 chart 무인증 JSON 호출 공통 — User-Agent 헤더는 DI 에서 기본 부여(Yahoo 는 UA 없으면 거부).
+/// 간단 3회 재시도(2s/4s 백오프). 404(심볼 없음)는 재시도 없이 null 반환 → 호출측이 해당 심볼 skip.
+/// Stooq 봇차단(JS PoW) 도입으로 세계지수·종목 수집 소스를 Yahoo v8 로 전환(2026-07-09).
+/// </summary>
+internal static class YahooFinanceHttp
+{
+    public const string ClientName = "YahooFinance";
+
+    private const int MaxAttempts = 3;
+
+    /// <summary>성공 시 응답 본문(JSON), 404 면 null. 그 외 실패는 재시도 후 예외.</summary>
+    public static async Task<string?> GetStringWithRetryAsync(HttpClient client, string url, ILogger logger, CancellationToken ct)
+    {
+        for (var attempt = 1; ; attempt++)
+        {
+            try
+            {
+                using var response = await client.GetAsync(url, ct);
+
+                if ((int)response.StatusCode == 404)
+                {
+                    return null;
+                }
+
+                response.EnsureSuccessStatusCode();
+                return await response.Content.ReadAsStringAsync(ct);
+            }
+            catch (Exception ex) when (ex is not OperationCanceledException && attempt < MaxAttempts)
+            {
+                logger.LogWarning(ex, "[Yahoo] HTTP 실패 — 재시도 {Attempt}/{Max}", attempt, MaxAttempts);
+                await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct);
+            }
+        }
+    }
+}

+ 4 - 4
SharedKernel/AppSetting.cs

@@ -489,8 +489,8 @@ public sealed class AppSettings
         /// <summary>세계지수 수집 배치 (WorldIndexSyncService) 활성화</summary>
         /// <summary>세계지수 수집 배치 (WorldIndexSyncService) 활성화</summary>
         public bool Enabled { get; init; } = false;
         public bool Enabled { get; init; } = false;
 
 
-        /// <summary>Stooq 호스트 (경로·쿼리는 서비스가 구성)</summary>
-        public string BaseUrl { get; init; } = "https://stooq.com";
+        /// <summary>시세 호스트 (Yahoo Finance v8 — 경로·쿼리는 서비스가 구성)</summary>
+        public string BaseUrl { get; init; } = "https://query1.finance.yahoo.com";
 
 
         /// <summary>수집 실행 시각 (KST, "HH:mm") — 미국장 마감(익일 새벽 KST) 이후 확정치 확보 위해 07:00</summary>
         /// <summary>수집 실행 시각 (KST, "HH:mm") — 미국장 마감(익일 새벽 KST) 이후 확정치 확보 위해 07:00</summary>
         public string SyncTime { get; init; } = "07:00";
         public string SyncTime { get; init; } = "07:00";
@@ -530,8 +530,8 @@ public sealed class AppSettings
         /// <summary>시세 수집 배치 (MarketQuoteSyncService) 활성화</summary>
         /// <summary>시세 수집 배치 (MarketQuoteSyncService) 활성화</summary>
         public bool Enabled { get; init; } = false;
         public bool Enabled { get; init; } = false;
 
 
-        /// <summary>Stooq 호스트 (경로·쿼리는 서비스가 구성)</summary>
-        public string BaseUrl { get; init; } = "https://stooq.com";
+        /// <summary>시세 호스트 (Yahoo Finance v8 — 경로·쿼리는 서비스가 구성)</summary>
+        public string BaseUrl { get; init; } = "https://query1.finance.yahoo.com";
 
 
         /// <summary>수집 실행 시각 (KST, "HH:mm") — 세계지수(07:00) 직후 07:10</summary>
         /// <summary>수집 실행 시각 (KST, "HH:mm") — 세계지수(07:00) 직후 07:10</summary>
         public string SyncTime { get; init; } = "07:10";
         public string SyncTime { get; init; } = "07:10";

+ 46 - 0
Tests/Application.Tests/YahooChartParserTests.cs

@@ -0,0 +1,46 @@
+using Infrastructure.StockData;
+
+namespace Application.Tests;
+
+[TestClass]
+public sealed class YahooChartParserTests
+{
+    [TestMethod]
+    public void Parse_MapsMetaFields_LowercasesSymbol()
+    {
+        // Yahoo v8 chart 표준 응답 축약 (meta 만) — 실제 ^GSPC 응답 형태
+        const string json = """
+        {"chart":{"result":[{"meta":{"symbol":"^GSPC","regularMarketTime":1783543721,"gmtoffset":-14400,"regularMarketPrice":7482.71,"chartPreviousClose":7499.36,"regularMarketDayHigh":7488.51,"regularMarketDayLow":7421.82,"regularMarketVolume":2874621000}}],"error":null}}
+        """;
+
+        var q = YahooChartParser.Parse(json, "^GSPC");
+
+        Assert.IsNotNull(q);
+        Assert.AreEqual("^gspc", q.Symbol, "심볼은 소문자 정규화");
+        Assert.AreEqual(7482.71m, q.Close);
+        Assert.AreEqual(7488.51m, q.High);
+        Assert.AreEqual(7421.82m, q.Low);
+        Assert.AreEqual(2874621000L, q.Volume);
+        Assert.AreEqual(7482.71m, q.Open, "시가 미제공 → 종가로 대체");
+        Assert.AreEqual(2026, q.TradeDate.Year);
+        Assert.AreEqual(7, q.TradeDate.Month);
+    }
+
+    [TestMethod]
+    public void Parse_MissingPriceOrTime_ReturnsNull()
+    {
+        // 종가 없음
+        Assert.IsNull(YahooChartParser.Parse("""{"chart":{"result":[{"meta":{"symbol":"^X","regularMarketTime":1783543721}}]}}""", "^X"));
+        // 거래시각 없음
+        Assert.IsNull(YahooChartParser.Parse("""{"chart":{"result":[{"meta":{"symbol":"^X","regularMarketPrice":100.0}}]}}""", "^X"));
+    }
+
+    [TestMethod]
+    public void Parse_EmptyOrError_ReturnsNull()
+    {
+        Assert.IsNull(YahooChartParser.Parse("", "^X"));
+        Assert.IsNull(YahooChartParser.Parse("   ", "^X"));
+        Assert.IsNull(YahooChartParser.Parse("""{"chart":{"result":null,"error":{"code":"Not Found"}}}""", "^X"));
+        Assert.IsNull(YahooChartParser.Parse("not json", "^X"));
+    }
+}