using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Members; namespace Application.Helpers; /// /// 예측 채점 + 트랙레코드 집계의 순수 로직 (D2 M4 예측 채점+트랙레코드, d2 §③ 채점 규칙 v1). /// DB/Redis/HostedService 의존이 전혀 없어 단위 테스트가 가능하다 — Infrastructure 의 /// PredictionSettlementService 는 시세 조회/저장만 하고 판정·집계는 이 헬퍼에 위임한다. /// public static class PredictionScorer { /// 단건 채점 입력 — 이미 조회된 시세/종목 상태를 그대로 넣는다. /// 예측 방향 /// 작성 시점 기준가 /// 목표가 (없으면 null) /// DueDate 종가 (없으면 null → Void) /// 채점 시점 종목이 정상 거래 가능(상장 + TradingStatus.Normal)한지 public readonly record struct Input( PredictionDirection Direction, decimal BasePrice, decimal? TargetPrice, decimal? SettledPrice, bool Tradable); /// /// 단건 판정 (d2 §③ v1): /// /// 상폐/거래정지(!Tradable) 또는 시세 결측 → Void (표본 제외) /// TargetPrice 지정: Up 은 종가 ≥ 목표가면 Hit, Down 은 종가 ≤ 목표가면 Hit, 아니면 Miss /// 방향만: Up 은 종가 > 기준가면 Hit(동가 = Miss), Down 은 종가 < 기준가면 Hit(동가 = Miss) /// /// public static PredictionStatus Score(in Input input) { // 상폐·거래정지·시세결측 → 무효 (표본 제외) if (!input.Tradable || input.SettledPrice is not { } settled) { return PredictionStatus.Void; } if (input.TargetPrice is { } target) { var reached = input.Direction == PredictionDirection.Up ? settled >= target : settled <= target; return reached ? PredictionStatus.Hit : PredictionStatus.Miss; } // 방향만 — 동가(0% 변동)는 Miss var win = input.Direction == PredictionDirection.Up ? settled > input.BasePrice : settled < input.BasePrice; return win ? PredictionStatus.Hit : PredictionStatus.Miss; } /// 트랙레코드 재집계 결과 (엔티티에 그대로 반영). public readonly record struct TrackRecordAggregate( int Predictions, int Hits, int Misses, int Voids, decimal HitRate, int CurrentStreak, int BestStreak, TrackRecordTier Tier); /// /// 채점 완료된 상태들을 시간순(오래된→최신)으로 받아 트랙레코드를 처음부터 재집계한다 (멱등). /// HitRate = Hits/(Hits+Misses)*100 (소수 둘째자리 반올림, Void 제외). /// 스트릭은 Void 를 건너뛴 Hit/Miss 시퀀스 기준: BestStreak = 최장 연속 Hit, /// CurrentStreak = 가장 최근 Hit/Miss 부터 거슬러 올라간 연속 Hit(마지막이 Miss 면 0). /// Pending 은 호출 전에 제외되어 있어야 한다. /// public static TrackRecordAggregate Aggregate(IReadOnlyList chronological) { var hits = 0; var misses = 0; var voids = 0; var bestStreak = 0; var runningStreak = 0; var currentStreak = 0; foreach (var status in chronological) { switch (status) { case PredictionStatus.Hit: hits++; runningStreak++; if (runningStreak > bestStreak) { bestStreak = runningStreak; } currentStreak++; break; case PredictionStatus.Miss: misses++; runningStreak = 0; currentStreak = 0; break; case PredictionStatus.Void: // 표본 제외 — 스트릭에 영향 없음(연속을 끊지도, 이어가지도 않음) voids++; break; default: // Pending 은 채점 완료 목록에 있으면 안 됨 — 방어적으로 무시 break; } } var sample = hits + misses; var hitRate = sample > 0 ? Math.Round((decimal)hits / sample * 100m, 2, MidpointRounding.AwayFromZero) : 0m; var tier = TrackRecordTiers.Resolve(sample, hitRate); return new TrackRecordAggregate(hits + misses + voids, hits, misses, voids, hitRate, currentStreak, bestStreak, tier); } }