PredictionScorer.cs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. using Domain.Entities.Forum.Posts.ValueObject;
  2. using Domain.Entities.Members;
  3. namespace Application.Helpers;
  4. /// <summary>
  5. /// 예측 채점 + 트랙레코드 집계의 순수 로직 (D2 M4 예측 채점+트랙레코드, d2 §③ 채점 규칙 v1).
  6. /// DB/Redis/HostedService 의존이 전혀 없어 단위 테스트가 가능하다 — Infrastructure 의
  7. /// PredictionSettlementService 는 시세 조회/저장만 하고 판정·집계는 이 헬퍼에 위임한다.
  8. /// </summary>
  9. public static class PredictionScorer
  10. {
  11. /// <summary>단건 채점 입력 — 이미 조회된 시세/종목 상태를 그대로 넣는다.</summary>
  12. /// <param name="Direction">예측 방향</param>
  13. /// <param name="BasePrice">작성 시점 기준가</param>
  14. /// <param name="TargetPrice">목표가 (없으면 null)</param>
  15. /// <param name="SettledPrice">DueDate 종가 (없으면 null → Void)</param>
  16. /// <param name="Tradable">채점 시점 종목이 정상 거래 가능(상장 + TradingStatus.Normal)한지</param>
  17. public readonly record struct Input(
  18. PredictionDirection Direction,
  19. decimal BasePrice,
  20. decimal? TargetPrice,
  21. decimal? SettledPrice,
  22. bool Tradable);
  23. /// <summary>
  24. /// 단건 판정 (d2 §③ v1):
  25. /// <list type="bullet">
  26. /// <item>상폐/거래정지(!Tradable) 또는 시세 결측 → Void (표본 제외)</item>
  27. /// <item>TargetPrice 지정: Up 은 종가 ≥ 목표가면 Hit, Down 은 종가 ≤ 목표가면 Hit, 아니면 Miss</item>
  28. /// <item>방향만: Up 은 종가 &gt; 기준가면 Hit(동가 = Miss), Down 은 종가 &lt; 기준가면 Hit(동가 = Miss)</item>
  29. /// </list>
  30. /// </summary>
  31. public static PredictionStatus Score(in Input input)
  32. {
  33. // 상폐·거래정지·시세결측 → 무효 (표본 제외)
  34. if (!input.Tradable || input.SettledPrice is not { } settled)
  35. {
  36. return PredictionStatus.Void;
  37. }
  38. if (input.TargetPrice is { } target)
  39. {
  40. var reached = input.Direction == PredictionDirection.Up ? settled >= target : settled <= target;
  41. return reached ? PredictionStatus.Hit : PredictionStatus.Miss;
  42. }
  43. // 방향만 — 동가(0% 변동)는 Miss
  44. var win = input.Direction == PredictionDirection.Up ? settled > input.BasePrice : settled < input.BasePrice;
  45. return win ? PredictionStatus.Hit : PredictionStatus.Miss;
  46. }
  47. /// <summary>트랙레코드 재집계 결과 (엔티티에 그대로 반영).</summary>
  48. public readonly record struct TrackRecordAggregate(
  49. int Predictions,
  50. int Hits,
  51. int Misses,
  52. int Voids,
  53. decimal HitRate,
  54. int CurrentStreak,
  55. int BestStreak,
  56. TrackRecordTier Tier);
  57. /// <summary>
  58. /// 채점 완료된 상태들을 <b>시간순(오래된→최신)</b>으로 받아 트랙레코드를 처음부터 재집계한다 (멱등).
  59. /// HitRate = Hits/(Hits+Misses)*100 (소수 둘째자리 반올림, Void 제외).
  60. /// 스트릭은 Void 를 건너뛴 Hit/Miss 시퀀스 기준: BestStreak = 최장 연속 Hit,
  61. /// CurrentStreak = 가장 최근 Hit/Miss 부터 거슬러 올라간 연속 Hit(마지막이 Miss 면 0).
  62. /// Pending 은 호출 전에 제외되어 있어야 한다.
  63. /// </summary>
  64. public static TrackRecordAggregate Aggregate(IReadOnlyList<PredictionStatus> chronological)
  65. {
  66. var hits = 0;
  67. var misses = 0;
  68. var voids = 0;
  69. var bestStreak = 0;
  70. var runningStreak = 0;
  71. var currentStreak = 0;
  72. foreach (var status in chronological)
  73. {
  74. switch (status)
  75. {
  76. case PredictionStatus.Hit:
  77. hits++;
  78. runningStreak++;
  79. if (runningStreak > bestStreak)
  80. {
  81. bestStreak = runningStreak;
  82. }
  83. currentStreak++;
  84. break;
  85. case PredictionStatus.Miss:
  86. misses++;
  87. runningStreak = 0;
  88. currentStreak = 0;
  89. break;
  90. case PredictionStatus.Void:
  91. // 표본 제외 — 스트릭에 영향 없음(연속을 끊지도, 이어가지도 않음)
  92. voids++;
  93. break;
  94. default:
  95. // Pending 은 채점 완료 목록에 있으면 안 됨 — 방어적으로 무시
  96. break;
  97. }
  98. }
  99. var sample = hits + misses;
  100. var hitRate = sample > 0 ? Math.Round((decimal)hits / sample * 100m, 2, MidpointRounding.AwayFromZero) : 0m;
  101. var tier = TrackRecordTiers.Resolve(sample, hitRate);
  102. return new TrackRecordAggregate(hits + misses + voids, hits, misses, voids, hitRate, currentStreak, bestStreak, tier);
  103. }
  104. }