using Application.Abstractions.Data; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Helpers; /// /// 예측 채점 배치 엔진 (D2 M4 예측 채점+트랙레코드, d2 §③·§⑥ PredictionSettlementService). /// D1 이 T+1 종가를 적재하면 DueDate 도래한 Pending 예측을 종가로 채점하고, 영향받은 회원의 /// MemberTrackRecord 를 처음부터 재집계한다. /// /// 스케줄/알림/Redis 는 Infrastructure 의 HostedService(PredictionSettlementService)가 담당하고, /// 여기서는 순수 배치 로직만 다뤄 IAppDbContext 만으로 단위 테스트가 가능하도록 분리했다 (PaperBatchEngine 패턴). /// /// 정책: /// • 예측글이 삭제(Post.IsDeleted)돼도 예측은 채점한다 — 삭제 회피 어뷰징 차단(d2 §③). Post 조인/필터 없음. /// • 종가 = DueDate 당일 종가. 당일 시세가 없으면 DueDate 이하 가장 가까운 직전 거래일 종가로 대체(주말/휴장 대비). /// • 종목이 상폐(IsActive=false)/거래정지(TradingStatus!=Normal) 또는 대체할 종가도 없으면 Void. /// • MemberTrackRecord 재집계는 회원의 전체 non-Pending 예측을 SettledAt 오름차순으로 다시 계산 → 멱등. /// public static class PredictionSettlementEngine { /// 채점 결과 요약 (배치 로그/테스트용). public readonly record struct SettlePassResult(int Considered, int Hits, int Misses, int Voids, int MembersRecomputed); /// 채점된 단건 — 작성자 알림에 사용. public readonly record struct SettledPrediction(int PredictionID, int PostID, int MemberID, string StockCode, PredictionStatus Status); /// /// asOfDate(포함) 이하 DueDate 의 Pending 예측을 채점하고 영향받은 회원 트랙레코드를 재집계한다. /// 채점된 단건 목록을 반환한다(호출자가 작성자 알림에 사용). 단일 컨텍스트, 단일 커밋. /// public static async Task<(SettlePassResult Result, IReadOnlyList Settled)> RunAsync( IAppDbContext db, DateOnly asOfDate, CancellationToken ct) { // Post 조인 없음 — 삭제글도 채점 (삭제 회피 어뷰징 차단) var pendings = await db.PostPrediction .Where(c => c.Status == PredictionStatus.Pending && c.DueDate <= asOfDate) .OrderBy(c => c.DueDate).ThenBy(c => c.ID) .ToListAsync(ct); if (pendings.Count == 0) { return (new SettlePassResult(0, 0, 0, 0, 0), []); } var codes = pendings.Select(c => c.StockCode).Distinct().ToList(); // Stock.Code → (ID, 거래가능 여부) 맵 (FK 미사용 — Code 로 조회) var stocks = await db.Stock.AsNoTracking() .Where(c => codes.Contains(c.Code)) .Select(c => new { c.Code, c.ID, c.IsActive, c.TradingStatus }) .ToListAsync(ct); var stockByCode = stocks.ToDictionary(c => c.Code); var hits = 0; var misses = 0; var voids = 0; var settled = new List(pendings.Count); var affectedMembers = new HashSet(); foreach (var prediction in pendings) { if (ct.IsCancellationRequested) { break; } decimal? settledPrice = null; var tradable = false; if (stockByCode.TryGetValue(prediction.StockCode, out var stock)) { tradable = stock.IsActive && stock.TradingStatus == TradingStatus.Normal; // DueDate 당일 종가 우선, 없으면 그 이하 가장 가까운 직전 거래일 종가 var close = await db.StockDailyPrice.AsNoTracking() .Where(c => c.StockID == stock.ID && c.TradingDate <= prediction.DueDate) .OrderByDescending(c => c.TradingDate) .Select(c => (int?)c.Close) .FirstOrDefaultAsync(ct); settledPrice = close.HasValue ? close.Value : null; } var input = new PredictionScorer.Input( prediction.Direction, prediction.BasePrice, prediction.TargetPrice, settledPrice, tradable); var status = PredictionScorer.Score(in input); // Void 는 종가를 기록하지 않는다(표본 제외 — 값 없음이 자연스러움) prediction.Settle(status, status == PredictionStatus.Void ? null : settledPrice); switch (status) { case PredictionStatus.Hit: hits++; break; case PredictionStatus.Miss: misses++; break; default: voids++; break; } settled.Add(new SettledPrediction(prediction.ID, prediction.PostID, prediction.MemberID, prediction.StockCode, status)); affectedMembers.Add(prediction.MemberID); } // 채점 결과를 먼저 커밋한다 — 재집계는 DB 를 읽으므로(AsNoTracking) 확정 상태가 반영돼 있어야 한다. await db.SaveChangesAsync(ct); // 영향받은 회원 트랙레코드 재집계 (전체 non-Pending 예측으로 처음부터 — 멱등) foreach (var memberID in affectedMembers) { await RecomputeMemberAsync(db, memberID, ct); } await db.SaveChangesAsync(ct); return (new SettlePassResult(pendings.Count, hits, misses, voids, affectedMembers.Count), settled); } /// /// 한 회원의 MemberTrackRecord 를 그의 전체 채점 완료(non-Pending) 예측으로 재집계한다. /// SettledAt 오름차순(동시각은 ID)으로 상태 시퀀스를 만들어 PredictionScorer.Aggregate 에 위임 — 멱등. /// 트랙레코드 행이 없으면 생성한다. /// public static async Task RecomputeMemberAsync(IAppDbContext db, int memberID, CancellationToken ct) { var statuses = await db.PostPrediction.AsNoTracking() .Where(c => c.MemberID == memberID && c.Status != PredictionStatus.Pending) .OrderBy(c => c.SettledAt).ThenBy(c => c.ID) .Select(c => c.Status) .ToListAsync(ct); var agg = PredictionScorer.Aggregate(statuses); var record = await db.MemberTrackRecord.FirstOrDefaultAsync(c => c.MemberID == memberID, ct); if (record is null) { record = MemberTrackRecord.Create(memberID); await db.MemberTrackRecord.AddAsync(record, ct); } record.Recompute(agg.Predictions, agg.Hits, agg.Misses, agg.Voids, agg.HitRate, agg.CurrentStreak, agg.BestStreak, agg.Tier); } }