using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Stocks.GetDomesticSummary; /// /// 국내 증시 요약 — 최신 거래일의 코스피·코스닥·KOSPI200 지수(IndexDailyPrice) + 시장별 등락종목수(StockDailyPrice×Stock). /// 상한/하한은 가격제한 플래그가 없어 등락률 ±29.5% 근사. (투자자별 순매수·베이시스는 미지원으로 응답에서 제외) /// internal sealed class Handler(IAppDbContext db) : IQueryHandler { private const decimal LimitThreshold = 29.5m; // 가격제한(±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); if (latestIndexDate is not DateOnly indexDate) { return new Response { List = rows }; } // 시장별 등락종목수 (최신 종목 거래일 — 지수일과 다를 수 있어 별도 산출) var stockDate = await db.StockDailyPrice.AsNoTracking() .OrderByDescending(c => c.TradingDate) .Select(c => (DateOnly?)c.TradingDate) .FirstOrDefaultAsync(ct); var breadthByMarket = new Dictionary(); if (stockDate is DateOnly sd) { // 익명 타입으로 집계(EF GroupBy 변환 안전) 후 메모리에서 매핑 var breadth = await db.StockDailyPrice.AsNoTracking() .Where(c => c.TradingDate == sd) .Join(db.Stock.AsNoTracking().Where(s => s.IsActive), p => p.StockID, s => s.ID, (p, s) => new { s.Market, p.ChangeRate }) .GroupBy(x => x.Market) .Select(g => new { Market = g.Key, Advances = g.Count(x => x.ChangeRate > 0m), Declines = g.Count(x => x.ChangeRate < 0m), Unchanged = g.Count(x => x.ChangeRate == 0m), LimitUp = g.Count(x => x.ChangeRate >= LimitThreshold), LimitDown = g.Count(x => x.ChangeRate <= -LimitThreshold) }) .ToListAsync(ct); breadthByMarket = breadth.ToDictionary( b => b.Market, b => new BreadthRow { Market = b.Market, Advances = b.Advances, Declines = b.Declines, Unchanged = b.Unchanged, LimitUp = b.LimitUp, LimitDown = b.LimitDown }); } // 코스피 await AddIndexAsync(rows, "kospi", MarketIndexSeries.KOSPI, "코스피", indexDate, breadthByMarket, StockMarket.KOSPI, ct); // 코스닥 await AddIndexAsync(rows, "kosdaq", MarketIndexSeries.KOSDAQ, "코스닥", indexDate, breadthByMarket, StockMarket.KOSDAQ, ct); // KOSPI200 (KOSPI 계열 내 "200" 포함 지수) — 등락종목수 없음, 베이시스는 Phase 2 await AddKospi200Async(rows, indexDate, ct); return new Response { List = rows }; } private async Task AddIndexAsync( List rows, string key, MarketIndexSeries series, string indexName, DateOnly indexDate, Dictionary breadthByMarket, StockMarket market, 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; } breadthByMarket.TryGetValue(market, out var b); rows.Add(new Response.Row { Key = key, Name = indexName, Close = idx.Close, ChangeVal = idx.ChangeVal, FlucRateBp = idx.FlucRateBp, TradeDate = indexDate.ToString("yyyy-MM-dd"), Advances = b?.Advances, Declines = b?.Declines, Unchanged = b?.Unchanged, LimitUp = b?.LimitUp, LimitDown = b?.LimitDown }); } 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") }); } private sealed class BreadthRow { public StockMarket Market { get; init; } public int Advances { get; init; } public int Declines { get; init; } public int Unchanged { get; init; } public int LimitUp { get; init; } public int LimitDown { get; init; } } }