| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117 |
- using Domain.Entities.Forum.Posts.ValueObject;
- using Domain.Entities.Members;
- namespace Application.Helpers;
- /// <summary>
- /// 예측 채점 + 트랙레코드 집계의 순수 로직 (D2 M4 예측 채점+트랙레코드, d2 §③ 채점 규칙 v1).
- /// DB/Redis/HostedService 의존이 전혀 없어 단위 테스트가 가능하다 — Infrastructure 의
- /// PredictionSettlementService 는 시세 조회/저장만 하고 판정·집계는 이 헬퍼에 위임한다.
- /// </summary>
- public static class PredictionScorer
- {
- /// <summary>단건 채점 입력 — 이미 조회된 시세/종목 상태를 그대로 넣는다.</summary>
- /// <param name="Direction">예측 방향</param>
- /// <param name="BasePrice">작성 시점 기준가</param>
- /// <param name="TargetPrice">목표가 (없으면 null)</param>
- /// <param name="SettledPrice">DueDate 종가 (없으면 null → Void)</param>
- /// <param name="Tradable">채점 시점 종목이 정상 거래 가능(상장 + TradingStatus.Normal)한지</param>
- public readonly record struct Input(
- PredictionDirection Direction,
- decimal BasePrice,
- decimal? TargetPrice,
- decimal? SettledPrice,
- bool Tradable);
- /// <summary>
- /// 단건 판정 (d2 §③ v1):
- /// <list type="bullet">
- /// <item>상폐/거래정지(!Tradable) 또는 시세 결측 → Void (표본 제외)</item>
- /// <item>TargetPrice 지정: Up 은 종가 ≥ 목표가면 Hit, Down 은 종가 ≤ 목표가면 Hit, 아니면 Miss</item>
- /// <item>방향만: Up 은 종가 > 기준가면 Hit(동가 = Miss), Down 은 종가 < 기준가면 Hit(동가 = Miss)</item>
- /// </list>
- /// </summary>
- 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;
- }
- /// <summary>트랙레코드 재집계 결과 (엔티티에 그대로 반영).</summary>
- public readonly record struct TrackRecordAggregate(
- int Predictions,
- int Hits,
- int Misses,
- int Voids,
- decimal HitRate,
- int CurrentStreak,
- int BestStreak,
- TrackRecordTier Tier);
- /// <summary>
- /// 채점 완료된 상태들을 <b>시간순(오래된→최신)</b>으로 받아 트랙레코드를 처음부터 재집계한다 (멱등).
- /// HitRate = Hits/(Hits+Misses)*100 (소수 둘째자리 반올림, Void 제외).
- /// 스트릭은 Void 를 건너뛴 Hit/Miss 시퀀스 기준: BestStreak = 최장 연속 Hit,
- /// CurrentStreak = 가장 최근 Hit/Miss 부터 거슬러 올라간 연속 Hit(마지막이 Miss 면 0).
- /// Pending 은 호출 전에 제외되어 있어야 한다.
- /// </summary>
- public static TrackRecordAggregate Aggregate(IReadOnlyList<PredictionStatus> 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);
- }
- }
|