Handler.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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" 포함 지수) — 등락종목수 없음, 베이시스는 Phase 2
  65. await AddKospi200Async(rows, indexDate, ct);
  66. return new Response { List = rows };
  67. }
  68. private async Task AddIndexAsync(
  69. List<Response.Row> rows,
  70. string key,
  71. MarketIndexSeries series,
  72. string indexName,
  73. DateOnly indexDate,
  74. Dictionary<StockMarket, BreadthRow> breadthByMarket,
  75. StockMarket market,
  76. CancellationToken ct
  77. ) {
  78. var idx = await db.IndexDailyPrice.AsNoTracking()
  79. .Where(c => c.TradeDate == indexDate && c.Series == series && c.IndexName == indexName)
  80. .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp })
  81. .FirstOrDefaultAsync(ct);
  82. if (idx is null)
  83. {
  84. return;
  85. }
  86. breadthByMarket.TryGetValue(market, out var b);
  87. rows.Add(new Response.Row
  88. {
  89. Key = key,
  90. Name = indexName,
  91. Close = idx.Close,
  92. ChangeVal = idx.ChangeVal,
  93. FlucRateBp = idx.FlucRateBp,
  94. TradeDate = indexDate.ToString("yyyy-MM-dd"),
  95. Advances = b?.Advances,
  96. Declines = b?.Declines,
  97. Unchanged = b?.Unchanged,
  98. LimitUp = b?.LimitUp,
  99. LimitDown = b?.LimitDown
  100. });
  101. }
  102. private async Task AddKospi200Async(List<Response.Row> rows, DateOnly indexDate, CancellationToken ct)
  103. {
  104. var idx = await db.IndexDailyPrice.AsNoTracking()
  105. .Where(c => c.TradeDate == indexDate && c.Series == MarketIndexSeries.KOSPI && c.IndexName.Contains("200"))
  106. .OrderBy(c => c.IndexName)
  107. .Select(c => new { c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp })
  108. .FirstOrDefaultAsync(ct);
  109. if (idx is null)
  110. {
  111. return;
  112. }
  113. rows.Add(new Response.Row
  114. {
  115. Key = "kospi200",
  116. Name = idx.IndexName,
  117. Close = idx.Close,
  118. ChangeVal = idx.ChangeVal,
  119. FlucRateBp = idx.FlucRateBp,
  120. TradeDate = indexDate.ToString("yyyy-MM-dd")
  121. });
  122. }
  123. private sealed class BreadthRow
  124. {
  125. public StockMarket Market { get; init; }
  126. public int Advances { get; init; }
  127. public int Declines { get; init; }
  128. public int Unchanged { get; init; }
  129. public int LimitUp { get; init; }
  130. public int LimitDown { get; init; }
  131. }
  132. }