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