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 [];
}
}
}