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);
}
}