Handler.cs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Domain.Entities.Stocks.ValueObject;
  4. using Microsoft.EntityFrameworkCore;
  5. namespace Application.Features.Api.Stocks.GetDomesticSummary;
  6. /// <summary>
  7. /// 국내 증시 요약 — 최신 거래일의 코스피·코스닥·KOSPI200 지수(IndexDailyPrice) + 시장별 등락종목수(StockDailyPrice×Stock).
  8. /// 상한/하한은 가격제한 플래그가 없어 등락률 ±29.5% 근사. (투자자별 순매수·베이시스는 미지원으로 응답에서 제외)
  9. /// </summary>
  10. internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
  11. {
  12. private const decimal LimitThreshold = 29.5m; // 가격제한(±30%) 근사
  13. public async Task<Response> Handle(Query request, CancellationToken ct)
  14. {
  15. var rows = new List<Response.Row>();
  16. // ── 지수 (최신 거래일) ──
  17. var latestIndexDate = await db.IndexDailyPrice.AsNoTracking()
  18. .OrderByDescending(c => c.TradeDate)
  19. .Select(c => (DateOnly?)c.TradeDate)
  20. .FirstOrDefaultAsync(ct);
  21. if (latestIndexDate is not DateOnly indexDate)
  22. {
  23. return new Response { List = rows };
  24. }
  25. // 시장별 등락종목수 (최신 종목 거래일 — 지수일과 다를 수 있어 별도 산출)
  26. var stockDate = await db.StockDailyPrice.AsNoTracking()
  27. .OrderByDescending(c => c.TradingDate)
  28. .Select(c => (DateOnly?)c.TradingDate)
  29. .FirstOrDefaultAsync(ct);
  30. var breadthByMarket = new Dictionary<StockMarket, BreadthRow>();
  31. if (stockDate is DateOnly sd)
  32. {
  33. // 익명 타입으로 집계(EF GroupBy 변환 안전) 후 메모리에서 매핑
  34. var breadth = await db.StockDailyPrice.AsNoTracking()
  35. .Where(c => c.TradingDate == sd)
  36. .Join(db.Stock.AsNoTracking().Where(s => s.IsActive), p => p.StockID, s => s.ID, (p, s) => new { s.Market, p.ChangeRate })
  37. .GroupBy(x => x.Market)
  38. .Select(g => new
  39. {
  40. Market = g.Key,
  41. Advances = g.Count(x => x.ChangeRate > 0m),
  42. Declines = g.Count(x => x.ChangeRate < 0m),
  43. Unchanged = g.Count(x => x.ChangeRate == 0m),
  44. LimitUp = g.Count(x => x.ChangeRate >= LimitThreshold),
  45. LimitDown = g.Count(x => x.ChangeRate <= -LimitThreshold)
  46. })
  47. .ToListAsync(ct);
  48. breadthByMarket = breadth.ToDictionary(
  49. b => b.Market,
  50. b => new BreadthRow
  51. {
  52. Market = b.Market,
  53. Advances = b.Advances,
  54. Declines = b.Declines,
  55. Unchanged = b.Unchanged,
  56. LimitUp = b.LimitUp,
  57. LimitDown = b.LimitDown
  58. });
  59. }
  60. // 코스피
  61. await AddIndexAsync(rows, "kospi", MarketIndexSeries.KOSPI, "코스피", indexDate, breadthByMarket, StockMarket.KOSPI, ct);
  62. // 코스닥
  63. await AddIndexAsync(rows, "kosdaq", MarketIndexSeries.KOSDAQ, "코스닥", indexDate, breadthByMarket, StockMarket.KOSDAQ, ct);
  64. // KOSPI200 (KOSPI 계열 내 "200" 포함 지수) — 등락종목수 없음
  65. await AddKospi200Async(rows, indexDate, ct);
  66. // 글로벌 벤치마크 — WorldIndexSnapshot 심볼로 조회 + 표시명 오버라이드 (등락종목수 없음)
  67. await AddGlobalAsync(rows, "sp500", "^gspc", "S&P 500", ct);
  68. await AddGlobalAsync(rows, "nasdaq", "^ixic", "나스닥", ct);
  69. return new Response { List = rows };
  70. }
  71. private async Task AddIndexAsync(
  72. List<Response.Row> rows,
  73. string key,
  74. MarketIndexSeries series,
  75. string indexName,
  76. DateOnly indexDate,
  77. Dictionary<StockMarket, BreadthRow> breadthByMarket,
  78. StockMarket market,
  79. CancellationToken ct
  80. ) {
  81. var idx = await db.IndexDailyPrice.AsNoTracking()
  82. .Where(c => c.TradeDate == indexDate && c.Series == series && c.IndexName == indexName)
  83. .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp })
  84. .FirstOrDefaultAsync(ct);
  85. if (idx is null)
  86. {
  87. return;
  88. }
  89. breadthByMarket.TryGetValue(market, out var b);
  90. rows.Add(new Response.Row
  91. {
  92. Key = key,
  93. Name = indexName,
  94. Close = idx.Close,
  95. ChangeVal = idx.ChangeVal,
  96. FlucRateBp = idx.FlucRateBp,
  97. TradeDate = indexDate.ToString("yyyy-MM-dd"),
  98. Advances = b?.Advances,
  99. Declines = b?.Declines,
  100. Unchanged = b?.Unchanged,
  101. LimitUp = b?.LimitUp,
  102. LimitDown = b?.LimitDown
  103. });
  104. }
  105. private async Task AddKospi200Async(List<Response.Row> rows, DateOnly indexDate, CancellationToken ct)
  106. {
  107. var idx = await db.IndexDailyPrice.AsNoTracking()
  108. .Where(c => c.TradeDate == indexDate && c.Series == MarketIndexSeries.KOSPI && c.IndexName.Contains("200"))
  109. .OrderBy(c => c.IndexName)
  110. .Select(c => new { c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp })
  111. .FirstOrDefaultAsync(ct);
  112. if (idx is null)
  113. {
  114. return;
  115. }
  116. rows.Add(new Response.Row
  117. {
  118. Key = "kospi200",
  119. Name = idx.IndexName,
  120. Close = idx.Close,
  121. ChangeVal = idx.ChangeVal,
  122. FlucRateBp = idx.FlucRateBp,
  123. TradeDate = indexDate.ToString("yyyy-MM-dd")
  124. });
  125. }
  126. private async Task AddGlobalAsync(List<Response.Row> rows, string key, string symbol, string name, CancellationToken ct)
  127. {
  128. var idx = await db.WorldIndexSnapshot.AsNoTracking()
  129. .Where(c => c.Symbol == symbol)
  130. .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp, c.TradeDate })
  131. .FirstOrDefaultAsync(ct);
  132. if (idx is null)
  133. {
  134. return;
  135. }
  136. rows.Add(new Response.Row
  137. {
  138. Key = key,
  139. Name = name,
  140. Close = idx.Close,
  141. ChangeVal = idx.ChangeVal,
  142. FlucRateBp = idx.FlucRateBp,
  143. TradeDate = idx.TradeDate.ToString("yyyy-MM-dd")
  144. });
  145. }
  146. private sealed class BreadthRow
  147. {
  148. public StockMarket Market { get; init; }
  149. public int Advances { get; init; }
  150. public int Declines { get; init; }
  151. public int Unchanged { get; init; }
  152. public int LimitUp { get; init; }
  153. public int LimitDown { get; init; }
  154. }
  155. }