using System.Text.Json; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Stocks.GetDomesticSummary; /// /// 국내 증시 요약(메인) — 최신 거래일의 코스피·코스닥·KOSPI200 지수(IndexDailyPrice) + 글로벌 벤치마크 S&P500·나스닥(WorldIndexSnapshot). /// 각 카드에 최근 종가 스파크라인(과거→최신) 포함: 국내는 IndexDailyPrice 시계열, 글로벌은 WorldIndexSnapshot.Spark(JSON). /// 등락종목수·상하한 top 집계는 GetMarketMovers 로 분리했다. /// internal sealed class Handler(IAppDbContext db) : IQueryHandler { private const int SparkPoints = 30; // 스파크라인 최근 거래일 수 public async Task Handle(Query request, CancellationToken ct) { var rows = new List(); var latestIndexDate = await db.IndexDailyPrice.AsNoTracking() .OrderByDescending(c => c.TradeDate) .Select(c => (DateOnly?)c.TradeDate) .FirstOrDefaultAsync(ct); // 국내 지수는 IndexDailyPrice 데이터가 있을 때만 (KRX 미수집·장중 등에도 글로벌 카드는 노출) if (latestIndexDate is DateOnly indexDate) { await AddIndexAsync(rows, "kospi", MarketIndexSeries.KOSPI, "코스피", indexDate, ct); await AddIndexAsync(rows, "kosdaq", MarketIndexSeries.KOSDAQ, "코스닥", indexDate, ct); await AddKospi200Async(rows, indexDate, ct); } // 글로벌 벤치마크 — IndexDailyPrice 유무와 무관하게 WorldIndexSnapshot 심볼로 항상 조회 + 표시명 오버라이드 await AddGlobalAsync(rows, "sp500", "^gspc", "S&P 500", ct); await AddGlobalAsync(rows, "nasdaq", "^ixic", "나스닥", ct); return new Response { List = rows }; } private async Task AddIndexAsync( List rows, string key, MarketIndexSeries series, string indexName, DateOnly indexDate, CancellationToken ct ) { var idx = await db.IndexDailyPrice.AsNoTracking() .Where(c => c.TradeDate == indexDate && c.Series == series && c.IndexName == indexName) .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp }) .FirstOrDefaultAsync(ct); if (idx is null) { return; } rows.Add(new Response.Row { Key = key, Name = indexName, Close = idx.Close, ChangeVal = idx.ChangeVal, FlucRateBp = idx.FlucRateBp, TradeDate = indexDate.ToString("yyyy-MM-dd"), Spark = await IndexSparkAsync(series, indexName, indexDate, ct) }); } private async Task AddKospi200Async(List rows, DateOnly indexDate, CancellationToken ct) { var idx = await db.IndexDailyPrice.AsNoTracking() .Where(c => c.TradeDate == indexDate && c.Series == MarketIndexSeries.KOSPI && c.IndexName.Contains("200")) .OrderBy(c => c.IndexName) .Select(c => new { c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp }) .FirstOrDefaultAsync(ct); if (idx is null) { return; } rows.Add(new Response.Row { Key = "kospi200", Name = idx.IndexName, Close = idx.Close, ChangeVal = idx.ChangeVal, FlucRateBp = idx.FlucRateBp, TradeDate = indexDate.ToString("yyyy-MM-dd"), Spark = await IndexSparkAsync(MarketIndexSeries.KOSPI, idx.IndexName, indexDate, ct) }); } private async Task AddGlobalAsync(List rows, string key, string symbol, string name, CancellationToken ct) { var idx = await db.WorldIndexSnapshot.AsNoTracking() .Where(c => c.Symbol == symbol) .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp, c.TradeDate, c.Spark }) .FirstOrDefaultAsync(ct); if (idx is null) { return; } rows.Add(new Response.Row { Key = key, Name = name, Close = idx.Close, ChangeVal = idx.ChangeVal, FlucRateBp = idx.FlucRateBp, TradeDate = idx.TradeDate.ToString("yyyy-MM-dd"), Spark = ParseSpark(idx.Spark) }); } /// 지수 최근 종가 시계열(과거→최신) — asOf 거래일 기준 SparkPoints 개. private async Task> IndexSparkAsync(MarketIndexSeries series, string indexName, DateOnly asOf, CancellationToken ct) { var recent = await db.IndexDailyPrice.AsNoTracking() .Where(c => c.Series == series && c.IndexName == indexName && c.TradeDate <= asOf) .OrderByDescending(c => c.TradeDate) .Take(SparkPoints) .Select(c => c.Close) .ToListAsync(ct); recent.Reverse(); return recent; } /// WorldIndexSnapshot.Spark(JSON decimal[]) 파싱 — null/오류는 빈 배열. private static IReadOnlyList ParseSpark(string? sparkJson) { if (string.IsNullOrWhiteSpace(sparkJson)) { return []; } try { return JsonSerializer.Deserialize>(sparkJson) ?? []; } catch (JsonException) { return []; } } }