| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.Stocks.GetMarketMovers;
- /// <summary>
- /// 등락 상위 종목(메인) — 최신 거래일 StockDailyPrice × Stock(active) 에서 코스피+코스닥 통합 상승/하락/상한/하한 각 상위 5.
- /// 상승/하락은 상한·하한(±29.5% 근사)을 제외해 4개 그룹이 겹치지 않도록 한다. 각 종목에 최근 종가 스파크라인(과거→최신) 포함.
- /// </summary>
- internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
- {
- private const int TopN = 5;
- private const int SparkPoints = 30;
- private const decimal LimitThreshold = 29.5m; // 가격제한(±30%) 근사
- public async Task<Response> 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)
- };
- }
- /// <summary>선택 종목들의 최근 종가 시계열(과거→최신) — StockID → 종가 리스트.</summary>
- private async Task<Dictionary<int, IReadOnlyList<int>>> SparkByStockAsync(List<int> 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<int>)g.OrderByDescending(x => x.TradingDate).Take(SparkPoints).OrderBy(x => x.TradingDate).Select(x => x.Close).ToList());
- }
- private static Response.Group BuildGroup(List<Mover> movers, int total, Dictionary<int, IReadOnlyList<int>> 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; }
- }
- }
|