| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- using Application.Helpers;
- using Domain.Entities.Forum.Posts.ValueObject;
- using Domain.Entities.Members;
- namespace Application.Tests;
- /// <summary>
- /// 예측 채점 + 트랙레코드 집계 순수 로직 검증 (D2 M4 예측 채점+트랙레코드, d2 §③).
- /// PredictionScorer 는 DB 의존이 없어 인메모리로 규칙 경계를 검증한다:
- /// Up/Down 방향, 동가=Miss, TargetPrice 도달, Void(시세 없음/상폐/거래정지),
- /// 트랙레코드 적중률·스트릭·Tier 경계.
- /// </summary>
- [TestClass]
- public sealed class PredictionScorerTests
- {
- private static PredictionScorer.Input Dir(PredictionDirection dir, decimal basePrice, decimal? settled, bool tradable = true)
- => new(dir, basePrice, null, settled, tradable);
- private static PredictionScorer.Input Target(PredictionDirection dir, decimal basePrice, decimal target, decimal? settled, bool tradable = true)
- => new(dir, basePrice, target, settled, tradable);
- // ── 방향 채점 ──────────────────────────────────────────────
- [TestMethod]
- public void Up_SettledAboveBase_IsHit()
- {
- Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10001m)));
- }
- [TestMethod]
- public void Up_SettledBelowBase_IsMiss()
- {
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 9999m)));
- }
- [TestMethod]
- public void Up_SettledEqualBase_IsMiss()
- {
- // 동가(0% 변동) = Miss (d2 §③ 채점 규칙 v1)
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 10000m)));
- }
- [TestMethod]
- public void Down_SettledBelowBase_IsHit()
- {
- Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 9999m)));
- }
- [TestMethod]
- public void Down_SettledAboveBase_IsMiss()
- {
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10001m)));
- }
- [TestMethod]
- public void Down_SettledEqualBase_IsMiss()
- {
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Dir(PredictionDirection.Down, 10000m, 10000m)));
- }
- // ── 목표가 채점 ─────────────────────────────────────────────
- [TestMethod]
- public void Up_WithTarget_ReachedExactly_IsHit()
- {
- // Up: 종가 ≥ 목표가면 Hit (경계 포함)
- Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 12000m)));
- }
- [TestMethod]
- public void Up_WithTarget_BelowTarget_IsMiss()
- {
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Up, 10000m, 12000m, 11999m)));
- }
- [TestMethod]
- public void Down_WithTarget_ReachedExactly_IsHit()
- {
- // Down: 종가 ≤ 목표가면 Hit
- Assert.AreEqual(PredictionStatus.Hit, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8000m)));
- }
- [TestMethod]
- public void Down_WithTarget_AboveTarget_IsMiss()
- {
- Assert.AreEqual(PredictionStatus.Miss, PredictionScorer.Score(Target(PredictionDirection.Down, 10000m, 8000m, 8001m)));
- }
- // ── Void ──────────────────────────────────────────────────
- [TestMethod]
- public void NoPrice_IsVoid()
- {
- Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, settled: null)));
- }
- [TestMethod]
- public void NotTradable_IsVoid_EvenWithPrice()
- {
- // 상폐/거래정지 → 종가가 있어도 Void
- Assert.AreEqual(PredictionStatus.Void, PredictionScorer.Score(Dir(PredictionDirection.Up, 10000m, 12000m, tradable: false)));
- }
- // ── 트랙레코드 집계 ───────────────────────────────────────
- [TestMethod]
- public void Aggregate_HitRate_ExcludesVoids()
- {
- // Hit 3, Miss 1, Void 2 → 표본=4, 적중률=75.00, Predictions=6
- var agg = PredictionScorer.Aggregate([
- PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit,
- PredictionStatus.Miss, PredictionStatus.Void, PredictionStatus.Hit
- ]);
- Assert.AreEqual(6, agg.Predictions);
- Assert.AreEqual(3, agg.Hits);
- Assert.AreEqual(1, agg.Misses);
- Assert.AreEqual(2, agg.Voids);
- Assert.AreEqual(75.00m, agg.HitRate);
- }
- [TestMethod]
- public void Aggregate_Streaks_IgnoreVoids()
- {
- // 시간순: H H V H M H H → BestStreak=3(마지막 세 H 중 V 무시로 첫 H H + H = 3), Current=2(마지막 M 이후 H H)
- var agg = PredictionScorer.Aggregate([
- PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Void, PredictionStatus.Hit,
- PredictionStatus.Miss, PredictionStatus.Hit, PredictionStatus.Hit
- ]);
- Assert.AreEqual(3, agg.BestStreak, "H H (V무시) H = 연속 3");
- Assert.AreEqual(2, agg.CurrentStreak, "마지막 Miss 이후 H H");
- }
- [TestMethod]
- public void Aggregate_CurrentStreak_ZeroWhenLastIsMiss()
- {
- var agg = PredictionScorer.Aggregate([PredictionStatus.Hit, PredictionStatus.Hit, PredictionStatus.Miss]);
- Assert.AreEqual(0, agg.CurrentStreak);
- Assert.AreEqual(2, agg.BestStreak);
- }
- [TestMethod]
- public void Aggregate_Empty_IsNoneTierZeroRate()
- {
- var agg = PredictionScorer.Aggregate([]);
- Assert.AreEqual(0, agg.Predictions);
- Assert.AreEqual(0m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.None, agg.Tier);
- }
- // ── Tier 경계 ─────────────────────────────────────────────
- [TestMethod]
- public void Tier_BelowMinSample_IsNone_EvenAt100Percent()
- {
- // 9건 전부 Hit → 100% 지만 표본 10 미만 → None
- var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).ToList();
- var agg = PredictionScorer.Aggregate(statuses);
- Assert.AreEqual(100.00m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.None, agg.Tier);
- }
- [TestMethod]
- public void Tier_Bronze_At50Percent_With10Sample()
- {
- // 10건 중 5 Hit → 50% → Bronze
- var statuses = Enumerable.Repeat(PredictionStatus.Hit, 5).Concat(Enumerable.Repeat(PredictionStatus.Miss, 5)).ToList();
- var agg = PredictionScorer.Aggregate(statuses);
- Assert.AreEqual(50.00m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.Bronze, agg.Tier);
- }
- [TestMethod]
- public void Tier_JustBelowBronze_IsNone()
- {
- // 20건 중 9 Hit = 45% → None (50 미만)
- var statuses = Enumerable.Repeat(PredictionStatus.Hit, 9).Concat(Enumerable.Repeat(PredictionStatus.Miss, 11)).ToList();
- var agg = PredictionScorer.Aggregate(statuses);
- Assert.AreEqual(45.00m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.None, agg.Tier);
- }
- [TestMethod]
- public void Tier_Silver_At60Percent()
- {
- var statuses = Enumerable.Repeat(PredictionStatus.Hit, 6).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList();
- var agg = PredictionScorer.Aggregate(statuses);
- Assert.AreEqual(60.00m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.Silver, agg.Tier);
- }
- [TestMethod]
- public void Tier_Gold_At70Percent()
- {
- var statuses = Enumerable.Repeat(PredictionStatus.Hit, 7).Concat(Enumerable.Repeat(PredictionStatus.Miss, 3)).ToList();
- var agg = PredictionScorer.Aggregate(statuses);
- Assert.AreEqual(70.00m, agg.HitRate);
- Assert.AreEqual(TrackRecordTier.Gold, agg.Tier);
- }
- [TestMethod]
- public void Tier_GoldAnt_Requires80PercentAnd30Sample()
- {
- // 20건 중 16 Hit = 80% 지만 표본 20 < 30 → Gold (황금개미 아님)
- var small = Enumerable.Repeat(PredictionStatus.Hit, 16).Concat(Enumerable.Repeat(PredictionStatus.Miss, 4)).ToList();
- var smallAgg = PredictionScorer.Aggregate(small);
- Assert.AreEqual(80.00m, smallAgg.HitRate);
- Assert.AreEqual(TrackRecordTier.Gold, smallAgg.Tier, "표본 30 미만이면 황금개미 아님");
- // 30건 중 24 Hit = 80% + 표본 30 → GoldAnt
- var big = Enumerable.Repeat(PredictionStatus.Hit, 24).Concat(Enumerable.Repeat(PredictionStatus.Miss, 6)).ToList();
- var bigAgg = PredictionScorer.Aggregate(big);
- Assert.AreEqual(80.00m, bigAgg.HitRate);
- Assert.AreEqual(TrackRecordTier.GoldAnt, bigAgg.Tier);
- }
- }
|