PredictionScorerTests.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. using Application.Helpers;
  2. using Domain.Entities.Forum.Posts.ValueObject;
  3. using Domain.Entities.Members;
  4. namespace Application.Tests;
  5. /// <summary>
  6. /// 예측 채점 + 트랙레코드 집계 순수 로직 검증 (D2 M4 예측 채점+트랙레코드, d2 §③).
  7. /// PredictionScorer 는 DB 의존이 없어 인메모리로 규칙 경계를 검증한다:
  8. /// Up/Down 방향, 동가=Miss, TargetPrice 도달, Void(시세 없음/상폐/거래정지),
  9. /// 트랙레코드 적중률·스트릭·Tier 경계.
  10. /// </summary>
  11. [TestClass]
  12. public sealed class PredictionScorerTests
  13. {
  14. private static PredictionScorer.Input Dir(PredictionDirection dir, decimal basePrice, decimal? settled, bool tradable = true)
  15. => new(dir, basePrice, null, settled, tradable);
  16. private static PredictionScorer.Input Target(PredictionDirection dir, decimal basePrice, decimal target, decimal? settled, bool tradable = true)
  17. => new(dir, basePrice, target, settled, tradable);
  18. // ── 방향 채점 ──────────────────────────────────────────────
  19. [TestMethod]
  20. public void Up_SettledAboveBase_IsHit()
  21. {
  22. Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10001m)));
  23. }
  24. [TestMethod]
  25. public void Up_SettledBelowBase_IsMiss()
  26. {
  27. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 9999m)));
  28. }
  29. [TestMethod]
  30. public void Up_SettledEqualBase_IsMiss()
  31. {
  32. // 동가(0% 변동) = Miss (d2 §③ 채점 규칙 v1)
  33. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10000m)));
  34. }
  35. [TestMethod]
  36. public void Down_SettledBelowBase_IsHit()
  37. {
  38. Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 9999m)));
  39. }
  40. [TestMethod]
  41. public void Down_SettledAboveBase_IsMiss()
  42. {
  43. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10001m)));
  44. }
  45. [TestMethod]
  46. public void Down_SettledEqualBase_IsMiss()
  47. {
  48. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10000m)));
  49. }
  50. // ── 목표가 채점 ─────────────────────────────────────────────
  51. [TestMethod]
  52. public void Up_WithTarget_ReachedExactly_IsHit()
  53. {
  54. // Up: 종가 ≥ 목표가면 Hit (경계 포함)
  55. Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 12000m)));
  56. }
  57. [TestMethod]
  58. public void Up_WithTarget_BelowTarget_IsMiss()
  59. {
  60. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 11999m)));
  61. }
  62. [TestMethod]
  63. public void Down_WithTarget_ReachedExactly_IsHit()
  64. {
  65. // Down: 종가 ≤ 목표가면 Hit
  66. Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8000m)));
  67. }
  68. [TestMethod]
  69. public void Down_WithTarget_AboveTarget_IsMiss()
  70. {
  71. Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8001m)));
  72. }
  73. // ── Void ──────────────────────────────────────────────────
  74. [TestMethod]
  75. public void NoPrice_IsVoid()
  76. {
  77. Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, settled: null)));
  78. }
  79. [TestMethod]
  80. public void NotTradable_IsVoid_EvenWithPrice()
  81. {
  82. // 상폐/거래정지 → 종가가 있어도 Void
  83. Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 12000m, tradable: false)));
  84. }
  85. // ── 트랙레코드 집계 ───────────────────────────────────────
  86. [TestMethod]
  87. public void Aggregate_HitRate_ExcludesVoids()
  88. {
  89. // Hit 3, Miss 1, Void 2 → 표본=4, 적중률=75.00, Predictions=6
  90. var agg = PredictionScorer.Aggregate([
  91. PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit,
  92. PredictionStatus.Miss, PredictionStatus.Void, PredictionStatus.Hit
  93. ]);
  94. Assert.AreEqual(6, agg.Predictions);
  95. Assert.AreEqual(3, agg.Hits);
  96. Assert.AreEqual(1, agg.Misses);
  97. Assert.AreEqual(2, agg.Voids);
  98. Assert.AreEqual(75.00m, agg.HitRate);
  99. }
  100. [TestMethod]
  101. public void Aggregate_Streaks_IgnoreVoids()
  102. {
  103. // 시간순: H H V H M H H → BestStreak=3(마지막 세 H 중 V 무시로 첫 H H + H = 3), Current=2(마지막 M 이후 H H)
  104. var agg = PredictionScorer.Aggregate([
  105. PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit,
  106. PredictionStatus.Miss, PredictionStatus.Hit, PredictionStatus.Hit
  107. ]);
  108. Assert.AreEqual(3, agg.BestStreak, "H H (V무시) H = 연속 3");
  109. Assert.AreEqual(2, agg.CurrentStreak, "마지막 Miss 이후 H H");
  110. }
  111. [TestMethod]
  112. public void Aggregate_CurrentStreak_ZeroWhenLastIsMiss()
  113. {
  114. var agg = PredictionScorer.Aggregate([PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Miss]);
  115. Assert.AreEqual(0, agg.CurrentStreak);
  116. Assert.AreEqual(2, agg.BestStreak);
  117. }
  118. [TestMethod]
  119. public void Aggregate_Empty_IsNoneTierZeroRate()
  120. {
  121. var agg = PredictionScorer.Aggregate([]);
  122. Assert.AreEqual(0, agg.Predictions);
  123. Assert.AreEqual(0m, agg.HitRate);
  124. Assert.AreEqual(TrackRecordTier.None, agg.Tier);
  125. }
  126. // ── Tier 경계 ─────────────────────────────────────────────
  127. [TestMethod]
  128. public void Tier_BelowMinSample_IsNone_EvenAt100Percent()
  129. {
  130. // 9건 전부 Hit → 100% 지만 표본 10 미만 → None
  131. var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).ToList();
  132. var agg = PredictionScorer.Aggregate(statuses);
  133. Assert.AreEqual(100.00m, agg.HitRate);
  134. Assert.AreEqual(TrackRecordTier.None, agg.Tier);
  135. }
  136. [TestMethod]
  137. public void Tier_Bronze_At50Percent_With10Sample()
  138. {
  139. // 10건 중 5 Hit → 50% → Bronze
  140. var statuses = Enumerable.Repeat(PredictionStatus.Hit, 5).Concat(Enumerable.Repeat(PredictionStatus.Miss, 5)).ToList();
  141. var agg = PredictionScorer.Aggregate(statuses);
  142. Assert.AreEqual(50.00m, agg.HitRate);
  143. Assert.AreEqual(TrackRecordTier.Bronze, agg.Tier);
  144. }
  145. [TestMethod]
  146. public void Tier_JustBelowBronze_IsNone()
  147. {
  148. // 20건 중 9 Hit = 45% → None (50 미만)
  149. var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).Concat(Enumerable.Repeat(PredictionStatus.Miss, 11)).ToList();
  150. var agg = PredictionScorer.Aggregate(statuses);
  151. Assert.AreEqual(45.00m, agg.HitRate);
  152. Assert.AreEqual(TrackRecordTier.None, agg.Tier);
  153. }
  154. [TestMethod]
  155. public void Tier_Silver_At60Percent()
  156. {
  157. var statuses = Enumerable.Repeat(PredictionStatus.Hit, 6).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList();
  158. var agg = PredictionScorer.Aggregate(statuses);
  159. Assert.AreEqual(60.00m, agg.HitRate);
  160. Assert.AreEqual(TrackRecordTier.Silver, agg.Tier);
  161. }
  162. [TestMethod]
  163. public void Tier_Gold_At70Percent()
  164. {
  165. var statuses = Enumerable.Repeat(PredictionStatus.Hit, 7).Concat(Enumerable.Repeat(PredictionStatus.Miss, 3)).ToList();
  166. var agg = PredictionScorer.Aggregate(statuses);
  167. Assert.AreEqual(70.00m, agg.HitRate);
  168. Assert.AreEqual(TrackRecordTier.Gold, agg.Tier);
  169. }
  170. [TestMethod]
  171. public void Tier_GoldAnt_Requires80PercentAnd30Sample()
  172. {
  173. // 20건 중 16 Hit = 80% 지만 표본 20 < 30 → Gold (황금개미 아님)
  174. var small = Enumerable.Repeat(PredictionStatus.Hit, 16).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList();
  175. var smallAgg = PredictionScorer.Aggregate(small);
  176. Assert.AreEqual(80.00m, smallAgg.HitRate);
  177. Assert.AreEqual(TrackRecordTier.Gold, smallAgg.Tier, "표본 30 미만이면 황금개미 아님");
  178. // 30건 중 24 Hit = 80% + 표본 30 → GoldAnt
  179. var big = Enumerable.Repeat(PredictionStatus.Hit, 24).Concat(Enumerable.Repeat(PredictionStatus.Miss, 6)).ToList();
  180. var bigAgg = PredictionScorer.Aggregate(big);
  181. Assert.AreEqual(80.00m, bigAgg.HitRate);
  182. Assert.AreEqual(TrackRecordTier.GoldAnt, bigAgg.Tier);
  183. }
  184. }