using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Stocks.GetMarketMovers; /// /// 등락 상위 종목(메인) — 최신 거래일 StockDailyPrice × Stock(active) 에서 코스피+코스닥 통합 상승/하락/상한/하한 각 상위 5. /// 상승/하락은 상한·하한(±29.5% 근사)을 제외해 4개 그룹이 겹치지 않도록 한다. 각 종목에 최근 종가 스파크라인(과거→최신) 포함. /// internal sealed class Handler(IAppDbContext db) : IQueryHandler { private const int TopN = 5; private const int SparkPoints = 30; private const decimal LimitThreshold = 29.5m; // 가격제한(±30%) 근사 public async Task Handle(Query request, CancellationToken ct) { var stockDate = await db.StockDailyPrice.AsNoTracking() .OrderByDescending(c => c.TradingDate) .Select(c => (DateOnly?)c.TradingDate) .FirstOrDefaultAsync(ct); if (stockDate is not DateOnly d) { return Empty(); } var rows = await db.StockDailyPrice.AsNoTracking() .Where(c => c.TradingDate == d) .Join(db.Stock.AsNoTracking().Where(s => s.IsActive), p => p.StockID, s => s.ID, (p, s) => new Mover { StockID = p.StockID, Code = s.Code, Name = s.Name, Market = (int)s.Market, Close = p.Close, ChangeVal = p.ChangeAmount, ChangeRate = p.ChangeRate }) .ToListAsync(ct); if (rows.Count == 0) { return Empty(); } var advances = rows.Where(c => c.ChangeRate > 0m && c.ChangeRate < LimitThreshold).OrderByDescending(c => c.ChangeRate).Take(TopN).ToList(); var declines = rows.Where(c => c.ChangeRate < 0m && c.ChangeRate > -LimitThreshold).OrderBy(c => c.ChangeRate).Take(TopN).ToList(); var limitUp = rows.Where(c => c.ChangeRate >= LimitThreshold).OrderByDescending(c => c.ChangeRate).Take(TopN).ToList(); var limitDown = rows.Where(c => c.ChangeRate <= -LimitThreshold).OrderBy(c => c.ChangeRate).Take(TopN).ToList(); // 스파크라인 — 선택된 종목(≤20)의 최근 종가 시계열을 한 번에 조회 var ids = advances.Concat(declines).Concat(limitUp).Concat(limitDown).Select(c => c.StockID).Distinct().ToList(); var sparkByStock = await SparkByStockAsync(ids, d, ct); return new Response { TradeDate = d.ToString("yyyy-MM-dd"), Advances = BuildGroup(advances, rows.Count(c => c.ChangeRate > 0m), sparkByStock), Declines = BuildGroup(declines, rows.Count(c => c.ChangeRate < 0m), sparkByStock), LimitUp = BuildGroup(limitUp, rows.Count(c => c.ChangeRate >= LimitThreshold), sparkByStock), LimitDown = BuildGroup(limitDown, rows.Count(c => c.ChangeRate <= -LimitThreshold), sparkByStock) }; } /// 선택 종목들의 최근 종가 시계열(과거→최신) — StockID → 종가 리스트. private async Task>> SparkByStockAsync(List ids, DateOnly asOf, CancellationToken ct) { if (ids.Count == 0) { return []; } // 최근 ~60일 창으로 스캔 하한 고정 (SparkPoints 거래일 ≈ 6주 → 60캘린더일이면 충분). 히스토리 누적에도 요청당 조회량 상한 유지. var floor = asOf.AddDays(-60); var raw = await db.StockDailyPrice.AsNoTracking() .Where(c => ids.Contains(c.StockID) && c.TradingDate <= asOf && c.TradingDate > floor) .Select(c => new { c.StockID, c.TradingDate, c.Close }) .ToListAsync(ct); return raw .GroupBy(c => c.StockID) .ToDictionary( g => g.Key, g => (IReadOnlyList)g.OrderByDescending(x => x.TradingDate).Take(SparkPoints).OrderBy(x => x.TradingDate).Select(x => x.Close).ToList()); } private static Response.Group BuildGroup(List movers, int total, Dictionary> sparkByStock) { var items = movers.Select(c => new Response.Item { Code = c.Code, Name = c.Name, Market = c.Market, Close = c.Close, ChangeVal = c.ChangeVal, FlucRateBp = (int)Math.Round(c.ChangeRate * 100m, MidpointRounding.AwayFromZero), Spark = sparkByStock.TryGetValue(c.StockID, out var s) ? s : [] }).ToList(); return new Response.Group { Total = total, Items = items }; } private static Response Empty() { var empty = new Response.Group { Total = 0, Items = [] }; return new Response { TradeDate = string.Empty, Advances = empty, Declines = empty, LimitUp = empty, LimitDown = empty }; } private sealed class Mover { public int StockID { get; init; } public string Code { get; init; } = default!; public string Name { get; init; } = default!; public int Market { get; init; } public int Close { get; init; } public int ChangeVal { get; init; } public decimal ChangeRate { get; init; } } }