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