Handler.cs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Microsoft.EntityFrameworkCore;
  4. namespace Application.Features.Api.Stocks.GetMarketMovers;
  5. /// <summary>
  6. /// 등락 상위 종목(메인) — 최신 거래일 StockDailyPrice × Stock(active) 에서 코스피+코스닥 통합 상승/하락/상한/하한 각 상위 5.
  7. /// 상승/하락은 상한·하한(±29.5% 근사)을 제외해 4개 그룹이 겹치지 않도록 한다. 각 종목에 최근 종가 스파크라인(과거→최신) 포함.
  8. /// </summary>
  9. internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
  10. {
  11. private const int TopN = 5;
  12. private const int SparkPoints = 30;
  13. private const decimal LimitThreshold = 29.5m; // 가격제한(±30%) 근사
  14. public async Task<Response> Handle(Query request, CancellationToken ct)
  15. {
  16. var stockDate = await db.StockDailyPrice.AsNoTracking()
  17. .OrderByDescending(c => c.TradingDate)
  18. .Select(c => (DateOnly?)c.TradingDate)
  19. .FirstOrDefaultAsync(ct);
  20. if (stockDate is not DateOnly d)
  21. {
  22. return Empty();
  23. }
  24. var rows = await db.StockDailyPrice.AsNoTracking()
  25. .Where(c => c.TradingDate == d)
  26. .Join(db.Stock.AsNoTracking().Where(s => s.IsActive), p => p.StockID, s => s.ID, (p, s) => new Mover
  27. {
  28. StockID = p.StockID,
  29. Code = s.Code,
  30. Name = s.Name,
  31. Market = (int)s.Market,
  32. Close = p.Close,
  33. ChangeVal = p.ChangeAmount,
  34. ChangeRate = p.ChangeRate
  35. })
  36. .ToListAsync(ct);
  37. if (rows.Count == 0)
  38. {
  39. return Empty();
  40. }
  41. var advances = rows.Where(c => c.ChangeRate > 0m && c.ChangeRate < LimitThreshold).OrderByDescending(c => c.ChangeRate).Take(TopN).ToList();
  42. var declines = rows.Where(c => c.ChangeRate < 0m && c.ChangeRate > -LimitThreshold).OrderBy(c => c.ChangeRate).Take(TopN).ToList();
  43. var limitUp = rows.Where(c => c.ChangeRate >= LimitThreshold).OrderByDescending(c => c.ChangeRate).Take(TopN).ToList();
  44. var limitDown = rows.Where(c => c.ChangeRate <= -LimitThreshold).OrderBy(c => c.ChangeRate).Take(TopN).ToList();
  45. // 스파크라인 — 선택된 종목(≤20)의 최근 종가 시계열을 한 번에 조회
  46. var ids = advances.Concat(declines).Concat(limitUp).Concat(limitDown).Select(c => c.StockID).Distinct().ToList();
  47. var sparkByStock = await SparkByStockAsync(ids, d, ct);
  48. return new Response
  49. {
  50. TradeDate = d.ToString("yyyy-MM-dd"),
  51. Advances = BuildGroup(advances, rows.Count(c => c.ChangeRate > 0m), sparkByStock),
  52. Declines = BuildGroup(declines, rows.Count(c => c.ChangeRate < 0m), sparkByStock),
  53. LimitUp = BuildGroup(limitUp, rows.Count(c => c.ChangeRate >= LimitThreshold), sparkByStock),
  54. LimitDown = BuildGroup(limitDown, rows.Count(c => c.ChangeRate <= -LimitThreshold), sparkByStock)
  55. };
  56. }
  57. /// <summary>선택 종목들의 최근 종가 시계열(과거→최신) — StockID → 종가 리스트.</summary>
  58. private async Task<Dictionary<int, IReadOnlyList<int>>> SparkByStockAsync(List<int> ids, DateOnly asOf, CancellationToken ct)
  59. {
  60. if (ids.Count == 0)
  61. {
  62. return [];
  63. }
  64. // 최근 ~60일 창으로 스캔 하한 고정 (SparkPoints 거래일 ≈ 6주 → 60캘린더일이면 충분). 히스토리 누적에도 요청당 조회량 상한 유지.
  65. var floor = asOf.AddDays(-60);
  66. var raw = await db.StockDailyPrice.AsNoTracking()
  67. .Where(c => ids.Contains(c.StockID) && c.TradingDate <= asOf && c.TradingDate > floor)
  68. .Select(c => new { c.StockID, c.TradingDate, c.Close })
  69. .ToListAsync(ct);
  70. return raw
  71. .GroupBy(c => c.StockID)
  72. .ToDictionary(
  73. g => g.Key,
  74. g => (IReadOnlyList<int>)g.OrderByDescending(x => x.TradingDate).Take(SparkPoints).OrderBy(x => x.TradingDate).Select(x => x.Close).ToList());
  75. }
  76. private static Response.Group BuildGroup(List<Mover> movers, int total, Dictionary<int, IReadOnlyList<int>> sparkByStock)
  77. {
  78. var items = movers.Select(c => new Response.Item
  79. {
  80. Code = c.Code,
  81. Name = c.Name,
  82. Market = c.Market,
  83. Close = c.Close,
  84. ChangeVal = c.ChangeVal,
  85. FlucRateBp = (int)Math.Round(c.ChangeRate * 100m, MidpointRounding.AwayFromZero),
  86. Spark = sparkByStock.TryGetValue(c.StockID, out var s) ? s : []
  87. }).ToList();
  88. return new Response.Group { Total = total, Items = items };
  89. }
  90. private static Response Empty()
  91. {
  92. var empty = new Response.Group { Total = 0, Items = [] };
  93. return new Response
  94. {
  95. TradeDate = string.Empty,
  96. Advances = empty,
  97. Declines = empty,
  98. LimitUp = empty,
  99. LimitDown = empty
  100. };
  101. }
  102. private sealed class Mover
  103. {
  104. public int StockID { get; init; }
  105. public string Code { get; init; } = default!;
  106. public string Name { get; init; } = default!;
  107. public int Market { get; init; }
  108. public int Close { get; init; }
  109. public int ChangeVal { get; init; }
  110. public decimal ChangeRate { get; init; }
  111. }
  112. }