PredictionSettlementEngine.cs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. using Application.Abstractions.Data;
  2. using Domain.Entities.Forum.Posts.ValueObject;
  3. using Domain.Entities.Members;
  4. using Domain.Entities.Stocks.ValueObject;
  5. using Microsoft.EntityFrameworkCore;
  6. namespace Application.Helpers;
  7. /// <summary>
  8. /// 예측 채점 배치 엔진 (D2 M4 예측 채점+트랙레코드, d2 §③·§⑥ PredictionSettlementService).
  9. /// D1 이 T+1 종가를 적재하면 DueDate 도래한 Pending 예측을 종가로 채점하고, 영향받은 회원의
  10. /// MemberTrackRecord 를 처음부터 재집계한다.
  11. ///
  12. /// 스케줄/알림/Redis 는 Infrastructure 의 HostedService(PredictionSettlementService)가 담당하고,
  13. /// 여기서는 순수 배치 로직만 다뤄 IAppDbContext 만으로 단위 테스트가 가능하도록 분리했다 (PaperBatchEngine 패턴).
  14. ///
  15. /// 정책:
  16. /// • 예측글이 삭제(Post.IsDeleted)돼도 예측은 채점한다 — 삭제 회피 어뷰징 차단(d2 §③). Post 조인/필터 없음.
  17. /// • 종가 = DueDate 당일 종가. 당일 시세가 없으면 DueDate 이하 가장 가까운 직전 거래일 종가로 대체(주말/휴장 대비).
  18. /// • 종목이 상폐(IsActive=false)/거래정지(TradingStatus!=Normal) 또는 대체할 종가도 없으면 Void.
  19. /// • MemberTrackRecord 재집계는 회원의 전체 non-Pending 예측을 SettledAt 오름차순으로 다시 계산 → 멱등.
  20. /// </summary>
  21. public static class PredictionSettlementEngine
  22. {
  23. /// <summary>채점 결과 요약 (배치 로그/테스트용).</summary>
  24. public readonly record struct SettlePassResult(int Considered, int Hits, int Misses, int Voids, int MembersRecomputed);
  25. /// <summary>채점된 단건 — 작성자 알림에 사용.</summary>
  26. public readonly record struct SettledPrediction(int PredictionID, int PostID, int MemberID, string StockCode, PredictionStatus Status);
  27. /// <summary>
  28. /// asOfDate(포함) 이하 DueDate 의 Pending 예측을 채점하고 영향받은 회원 트랙레코드를 재집계한다.
  29. /// 채점된 단건 목록을 반환한다(호출자가 작성자 알림에 사용). 단일 컨텍스트, 단일 커밋.
  30. /// </summary>
  31. public static async Task<(SettlePassResult Result, IReadOnlyList<SettledPrediction> Settled)> RunAsync(
  32. IAppDbContext db,
  33. DateOnly asOfDate,
  34. CancellationToken ct)
  35. {
  36. // Post 조인 없음 — 삭제글도 채점 (삭제 회피 어뷰징 차단)
  37. var pendings = await db.PostPrediction
  38. .Where(c => c.Status == PredictionStatus.Pending && c.DueDate <= asOfDate)
  39. .OrderBy(c => c.DueDate).ThenBy(c => c.ID)
  40. .ToListAsync(ct);
  41. if (pendings.Count == 0)
  42. {
  43. return (new SettlePassResult(0, 0, 0, 0, 0), []);
  44. }
  45. var codes = pendings.Select(c => c.StockCode).Distinct().ToList();
  46. // Stock.Code → (ID, 거래가능 여부) 맵 (FK 미사용 — Code 로 조회)
  47. var stocks = await db.Stock.AsNoTracking()
  48. .Where(c => codes.Contains(c.Code))
  49. .Select(c => new { c.Code, c.ID, c.IsActive, c.TradingStatus })
  50. .ToListAsync(ct);
  51. var stockByCode = stocks.ToDictionary(c => c.Code);
  52. var hits = 0;
  53. var misses = 0;
  54. var voids = 0;
  55. var settled = new List<SettledPrediction>(pendings.Count);
  56. var affectedMembers = new HashSet<int>();
  57. foreach (var prediction in pendings)
  58. {
  59. if (ct.IsCancellationRequested)
  60. {
  61. break;
  62. }
  63. decimal? settledPrice = null;
  64. var tradable = false;
  65. if (stockByCode.TryGetValue(prediction.StockCode, out var stock))
  66. {
  67. tradable = stock.IsActive && stock.TradingStatus == TradingStatus.Normal;
  68. // DueDate 당일 종가 우선, 없으면 그 이하 가장 가까운 직전 거래일 종가
  69. var close = await db.StockDailyPrice.AsNoTracking()
  70. .Where(c => c.StockID == stock.ID && c.TradingDate <= prediction.DueDate)
  71. .OrderByDescending(c => c.TradingDate)
  72. .Select(c => (int?)c.Close)
  73. .FirstOrDefaultAsync(ct);
  74. settledPrice = close.HasValue ? close.Value : null;
  75. }
  76. var input = new PredictionScorer.Input(
  77. prediction.Direction,
  78. prediction.BasePrice,
  79. prediction.TargetPrice,
  80. settledPrice,
  81. tradable);
  82. var status = PredictionScorer.Score(in input);
  83. // Void 는 종가를 기록하지 않는다(표본 제외 — 값 없음이 자연스러움)
  84. prediction.Settle(status, status == PredictionStatus.Void ? null : settledPrice);
  85. switch (status)
  86. {
  87. case PredictionStatus.Hit:
  88. hits++;
  89. break;
  90. case PredictionStatus.Miss:
  91. misses++;
  92. break;
  93. default:
  94. voids++;
  95. break;
  96. }
  97. settled.Add(new SettledPrediction(prediction.ID, prediction.PostID, prediction.MemberID, prediction.StockCode, status));
  98. affectedMembers.Add(prediction.MemberID);
  99. }
  100. // 채점 결과를 먼저 커밋한다 — 재집계는 DB 를 읽으므로(AsNoTracking) 확정 상태가 반영돼 있어야 한다.
  101. await db.SaveChangesAsync(ct);
  102. // 영향받은 회원 트랙레코드 재집계 (전체 non-Pending 예측으로 처음부터 — 멱등)
  103. foreach (var memberID in affectedMembers)
  104. {
  105. await RecomputeMemberAsync(db, memberID, ct);
  106. }
  107. await db.SaveChangesAsync(ct);
  108. return (new SettlePassResult(pendings.Count, hits, misses, voids, affectedMembers.Count), settled);
  109. }
  110. /// <summary>
  111. /// 한 회원의 MemberTrackRecord 를 그의 전체 채점 완료(non-Pending) 예측으로 재집계한다.
  112. /// SettledAt 오름차순(동시각은 ID)으로 상태 시퀀스를 만들어 PredictionScorer.Aggregate 에 위임 — 멱등.
  113. /// 트랙레코드 행이 없으면 생성한다.
  114. /// </summary>
  115. public static async Task RecomputeMemberAsync(IAppDbContext db, int memberID, CancellationToken ct)
  116. {
  117. var statuses = await db.PostPrediction.AsNoTracking()
  118. .Where(c => c.MemberID == memberID && c.Status != PredictionStatus.Pending)
  119. .OrderBy(c => c.SettledAt).ThenBy(c => c.ID)
  120. .Select(c => c.Status)
  121. .ToListAsync(ct);
  122. var agg = PredictionScorer.Aggregate(statuses);
  123. var record = await db.MemberTrackRecord.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  124. if (record is null)
  125. {
  126. record = MemberTrackRecord.Create(memberID);
  127. await db.MemberTrackRecord.AddAsync(record, ct);
  128. }
  129. record.Recompute(agg.Predictions, agg.Hits, agg.Misses, agg.Voids, agg.HitRate, agg.CurrentStreak, agg.BestStreak, agg.Tier);
  130. }
  131. }