| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- 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;
- /// <summary>
- /// 예측 채점 배치 엔진 (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 오름차순으로 다시 계산 → 멱등.
- /// </summary>
- public static class PredictionSettlementEngine
- {
- /// <summary>채점 결과 요약 (배치 로그/테스트용).</summary>
- public readonly record struct SettlePassResult(int Considered, int Hits, int Misses, int Voids, int MembersRecomputed);
- /// <summary>채점된 단건 — 작성자 알림에 사용.</summary>
- public readonly record struct SettledPrediction(int PredictionID, int PostID, int MemberID, string StockCode, PredictionStatus Status);
- /// <summary>
- /// asOfDate(포함) 이하 DueDate 의 Pending 예측을 채점하고 영향받은 회원 트랙레코드를 재집계한다.
- /// 채점된 단건 목록을 반환한다(호출자가 작성자 알림에 사용). 단일 컨텍스트, 단일 커밋.
- /// </summary>
- public static async Task<(SettlePassResult Result, IReadOnlyList<SettledPrediction> 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<SettledPrediction>(pendings.Count);
- var affectedMembers = new HashSet<int>();
- 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);
- }
- /// <summary>
- /// 한 회원의 MemberTrackRecord 를 그의 전체 채점 완료(non-Pending) 예측으로 재집계한다.
- /// SettledAt 오름차순(동시각은 ID)으로 상태 시퀀스를 만들어 PredictionScorer.Aggregate 에 위임 — 멱등.
- /// 트랙레코드 행이 없으면 생성한다.
- /// </summary>
- 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);
- }
- }
|