DailyBriefingComposer.cs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. using System.Globalization;
  2. using System.Text;
  3. using Application.Abstractions.Data;
  4. using Domain.Entities.Forum.Briefings;
  5. using Domain.Entities.Stocks.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using PostEntity = Domain.Entities.Forum.Posts.Post;
  8. namespace Application.Helpers;
  9. /// <summary>
  10. /// 장전 브리핑 본문 조립 + 발행 엔진 (d2 §③·§⑥ M3 DailyBriefingPublisher).
  11. /// 전일 지수(IndexDailyPrice 최신 KOSPI/KOSDAQ), 등락 상위/하위 종목(StockDailyPrice 최신 거래일 ChangeRate),
  12. /// 주요 공시(Disclosure 최근)를 취합해 briefing 보드에 시스템 계정(MemberID=null)으로 IsNotice Post 를 발행하고
  13. /// DailyBriefing 기록을 남긴다. BriefingDate 로 멱등(이미 발행됐으면 skip).
  14. ///
  15. /// 스케줄은 Infrastructure 의 HostedService(DailyBriefingPublisher)가 담당하고, 여기서는 순수 조립/발행 로직만
  16. /// 다뤄 IAppDbContext 만으로 단위 테스트가 가능하도록 분리했다 (PredictionSettlementEngine 패턴).
  17. /// 시스템 작성자 정책: Post.MemberID 는 nullable 이므로 전용 Member 없이 null + Name="개미투자" 로 발행한다(가장 단순).
  18. /// </summary>
  19. public static class DailyBriefingComposer
  20. {
  21. /// <summary>시스템 작성자 표시명 (Post.MemberID=null).</summary>
  22. public const string SystemAuthorName = "개미투자";
  23. private const int TopMoversCount = 5;
  24. private const int DisclosureCount = 10;
  25. /// <summary>발행 결과 (배치 로그/테스트용).</summary>
  26. public readonly record struct PublishResult(bool Published, int PostID, bool AlreadyExisted);
  27. /// <summary>
  28. /// briefingDate 기준 장전 브리핑을 조립·발행한다. 이미 해당 일자로 발행됐으면 skip(멱등).
  29. /// 발행 시 briefing 보드는 lazy 시드되며, 반환값으로 발행 여부/PostID 를 준다.
  30. /// </summary>
  31. public static async Task<PublishResult> PublishAsync(IAppDbContext db, DateOnly briefingDate, CancellationToken ct)
  32. {
  33. var existing = await db.DailyBriefing.AsNoTracking()
  34. .Where(c => c.BriefingDate == briefingDate)
  35. .Select(c => c.PostID)
  36. .FirstOrDefaultAsync(ct);
  37. if (existing != 0)
  38. {
  39. return new PublishResult(false, existing, true);
  40. }
  41. var body = await ComposeBodyAsync(db, ct);
  42. var subject = $"[장전 시황] {briefingDate:yyyy-MM-dd}";
  43. var boardID = await BoardSeeder.ResolveBriefingBoardIDAsync(db, ct);
  44. var post = new PostEntity
  45. {
  46. BoardID = boardID,
  47. MemberID = null,
  48. Name = SystemAuthorName,
  49. Subject = subject,
  50. Content = body,
  51. IsNotice = true
  52. };
  53. await db.Post.AddAsync(post, ct);
  54. await db.SaveChangesAsync(ct); // Post.ID(IDENTITY) 확보 — DailyBriefing FK 에 필요
  55. await db.DailyBriefing.AddAsync(DailyBriefing.Create(briefingDate, post.ID), ct);
  56. // Board 글 수 증가 (일반 작성 핸들러와 동일 계약)
  57. var boardEntity = await db.Board.FirstOrDefaultAsync(c => c.ID == boardID, ct);
  58. if (boardEntity is not null)
  59. {
  60. boardEntity.Posts++;
  61. boardEntity.UpdatedAt = DateTime.UtcNow;
  62. var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(c => c.ID == boardEntity.BoardGroupID, ct);
  63. if (boardGroup is not null)
  64. {
  65. boardGroup.Posts++;
  66. boardGroup.UpdatedAt = DateTime.UtcNow;
  67. }
  68. }
  69. await db.SaveChangesAsync(ct);
  70. return new PublishResult(true, post.ID, false);
  71. }
  72. /// <summary>전일 지수 + 등락 상위/하위 + 주요 공시로 HTML 본문을 조립한다.</summary>
  73. private static async Task<string> ComposeBodyAsync(IAppDbContext db, CancellationToken ct)
  74. {
  75. var sb = new StringBuilder();
  76. // ── 전일 지수 (KOSPI/KOSDAQ 최신 종가·등락) ──
  77. sb.Append("<h3>전일 지수</h3>");
  78. var indices = await LoadLatestIndicesAsync(db, ct);
  79. if (indices.Count > 0)
  80. {
  81. sb.Append("<ul>");
  82. foreach (var idx in indices)
  83. {
  84. var rate = (idx.FlucRateBp / 100m).ToString("0.00", CultureInfo.InvariantCulture);
  85. var sign = idx.FlucRateBp >= 0 ? "+" : "";
  86. sb.Append(CultureInfo.InvariantCulture, $"<li>{Escape(idx.IndexName)}: {idx.Close.ToString("N2", CultureInfo.InvariantCulture)} ({sign}{idx.ChangeVal.ToString("N2", CultureInfo.InvariantCulture)}, {sign}{rate}%)</li>");
  87. }
  88. sb.Append("</ul>");
  89. }
  90. else
  91. {
  92. sb.Append("<p>데이터 없음</p>");
  93. }
  94. // ── 등락 상위/하위 종목 (최신 거래일 ChangeRate) ──
  95. var latestTradeDate = await db.StockDailyPrice.AsNoTracking()
  96. .OrderByDescending(c => c.TradingDate)
  97. .Select(c => (DateOnly?)c.TradingDate)
  98. .FirstOrDefaultAsync(ct);
  99. if (latestTradeDate is DateOnly tradeDate)
  100. {
  101. var gainers = await LoadMoversAsync(db, tradeDate, top: true, ct);
  102. var losers = await LoadMoversAsync(db, tradeDate, top: false, ct);
  103. sb.Append("<h3>등락 상위</h3>");
  104. AppendMovers(sb, gainers);
  105. sb.Append("<h3>등락 하위</h3>");
  106. AppendMovers(sb, losers);
  107. }
  108. // ── 주요 공시 (최근 접수) ──
  109. sb.Append("<h3>주요 공시</h3>");
  110. var disclosures = await db.Disclosure.AsNoTracking()
  111. .OrderByDescending(c => c.RceptDt).ThenByDescending(c => c.RceptNo)
  112. .Take(DisclosureCount)
  113. .Select(c => new { c.CorpName, c.ReportNm, c.RceptDt })
  114. .ToListAsync(ct);
  115. if (disclosures.Count > 0)
  116. {
  117. sb.Append("<ul>");
  118. foreach (var d in disclosures)
  119. {
  120. sb.Append(CultureInfo.InvariantCulture, $"<li>[{d.RceptDt:yyyy-MM-dd}] {Escape(d.CorpName)} — {Escape(d.ReportNm)}</li>");
  121. }
  122. sb.Append("</ul>");
  123. }
  124. else
  125. {
  126. sb.Append("<p>데이터 없음</p>");
  127. }
  128. return sb.ToString();
  129. }
  130. private sealed record IndexRow(string IndexName, decimal Close, decimal ChangeVal, int FlucRateBp);
  131. /// <summary>KOSPI/KOSDAQ 각 계열에서 최신 거래일 지수 1건씩 로드.</summary>
  132. private static async Task<List<IndexRow>> LoadLatestIndicesAsync(IAppDbContext db, CancellationToken ct)
  133. {
  134. var rows = new List<IndexRow>();
  135. foreach (var series in new[] { MarketIndexSeries.KOSPI, MarketIndexSeries.KOSDAQ })
  136. {
  137. var idx = await db.IndexDailyPrice.AsNoTracking()
  138. .Where(c => c.Series == series)
  139. .OrderByDescending(c => c.TradeDate).ThenBy(c => c.IndexName)
  140. .Select(c => new IndexRow(c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp))
  141. .FirstOrDefaultAsync(ct);
  142. if (idx is not null)
  143. {
  144. rows.Add(idx);
  145. }
  146. }
  147. return rows;
  148. }
  149. private sealed record MoverRow(string Name, string Code, decimal ChangeRate, int Close);
  150. /// <summary>최신 거래일 종목 시세를 ChangeRate 내림차순(top)/오름차순으로 N건 로드 — Stock 조인으로 종목명 표기.</summary>
  151. private static async Task<List<MoverRow>> LoadMoversAsync(IAppDbContext db, DateOnly tradeDate, bool top, CancellationToken ct)
  152. {
  153. var query = db.StockDailyPrice.AsNoTracking().Where(c => c.TradingDate == tradeDate);
  154. var ordered = top ? query.OrderByDescending(c => c.ChangeRate) : query.OrderBy(c => c.ChangeRate);
  155. var rows = await ordered
  156. .Take(TopMoversCount)
  157. .Join(db.Stock.AsNoTracking(), price => price.StockID, stock => stock.ID, (price, stock) => new MoverRow(stock.Name, stock.Code, price.ChangeRate, price.Close))
  158. .ToListAsync(ct);
  159. return rows;
  160. }
  161. private static void AppendMovers(StringBuilder sb, List<MoverRow> movers)
  162. {
  163. if (movers.Count == 0)
  164. {
  165. sb.Append("<p>데이터 없음</p>");
  166. return;
  167. }
  168. sb.Append("<ul>");
  169. foreach (var m in movers)
  170. {
  171. var rate = m.ChangeRate.ToString("0.00", CultureInfo.InvariantCulture);
  172. var sign = m.ChangeRate >= 0 ? "+" : "";
  173. sb.Append(CultureInfo.InvariantCulture, $"<li>{Escape(m.Name)} (${m.Code}): {m.Close.ToString("N0", CultureInfo.InvariantCulture)}원 ({sign}{rate}%)</li>");
  174. }
  175. sb.Append("</ul>");
  176. }
  177. private static string Escape(string value)
  178. {
  179. return value.Replace("&", "&amp;", StringComparison.Ordinal).Replace("<", "&lt;", StringComparison.Ordinal).Replace(">", "&gt;", StringComparison.Ordinal);
  180. }
  181. }