| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- using Application.Abstractions.Data;
- using Application.Helpers;
- using Domain.Entities.Forum.Boards;
- using Domain.Entities.Forum.Posts;
- using Domain.Entities.Forum.Posts.ValueObject;
- using Domain.Entities.Members;
- using Domain.Entities.Stocks;
- using Domain.Entities.Stocks.ValueObject;
- using Infrastructure.Persistence;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Tests;
- /// <summary>
- /// 예측 채점 배치 통합 검증 (D2 M4 예측 채점+트랙레코드, d2 §③·§⑥) — PredictionSettlementEngine.
- /// Up/Down 채점, 동가=Miss, TargetPrice, 시세없음/상폐 Void, 삭제글도 채점,
- /// DueDate 당일 없을 때 직전 거래일 종가 대체, 트랙레코드 재집계·멱등을 LocalDB(antooza_test)로 검증한다.
- /// 공유 DB 이므로 종목코드는 96xxxx 고범위 + GUID 회원으로 격리한다.
- /// </summary>
- [TestClass]
- public sealed class PredictionSettlementTests
- {
- private static Func<IAppDbContext> Factory => () => TestDb.Create();
- private static int _codeSeq = 960000;
- /// <summary>테스트 간 고유 6자리 종목코드.</summary>
- private static string NextCode() => System.Threading.Interlocked.Increment(ref _codeSeq).ToString("D6");
- private static async Task<Stock> CreateStockAsync(AppDbContext db, string code, bool active = true, TradingStatus status = TradingStatus.Normal)
- {
- var stock = Stock.Create(code, $"테스트종목{code}", StockMarket.KOSPI, new DateOnly(2020, 1, 2));
- if (!active)
- {
- stock.MarkDelisted(new DateOnly(2026, 1, 2));
- }
- if (status != TradingStatus.Normal)
- {
- stock.SetTradingStatus(status);
- }
- await db.Stock.AddAsync(stock);
- await db.SaveChangesAsync(default);
- return stock;
- }
- private static async Task AddCloseAsync(AppDbContext db, int stockID, DateOnly date, int close)
- {
- await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stockID, date, close, close, close, close, 1000L, 1_000_000L, 0, 0m));
- await db.SaveChangesAsync(default);
- }
- /// <summary>회원 + stock 보드 Post 1건 생성 후 ID 반환.</summary>
- private static async Task<(int MemberID, int PostID)> CreateMemberWithPostAsync(AppDbContext db, string code, bool deleted = false)
- {
- var member = Member.Create($"pred-{Guid.NewGuid():N}@antooza.test");
- await db.Member.AddAsync(member);
- await db.SaveChangesAsync(default);
- var group = new BoardGroup { Code = $"g{Guid.NewGuid():N}"[..12], Name = "테스트그룹" };
- await db.BoardGroup.AddAsync(group);
- await db.SaveChangesAsync(default);
- var board = new Board { BoardGroupID = group.ID, Code = $"b{Guid.NewGuid():N}"[..12], Name = "테스트보드", IsActive = true };
- await db.Board.AddAsync(board);
- await db.SaveChangesAsync(default);
- var post = new Post
- {
- BoardID = board.ID,
- MemberID = member.ID,
- StockCode = code,
- Subject = "예측글",
- Content = "본문",
- Name = member.Name,
- SID = member.SID,
- Email = member.Email,
- IsDeleted = deleted
- };
- await db.Post.AddAsync(post);
- await db.SaveChangesAsync(default);
- return (member.ID, post.ID);
- }
- private static async Task AddPredictionAsync(AppDbContext db, int postID, int memberID, string code,
- PredictionDirection dir, decimal basePrice, DateOnly dueDate, decimal? target = null, short horizon = 5)
- {
- await db.PostPrediction.AddAsync(PostPrediction.Create(postID, memberID, code, dir, basePrice, horizon, dueDate, target));
- await db.SaveChangesAsync(default);
- }
- // ── 방향 채점 ──────────────────────────────────────────────
- [TestMethod]
- public async Task Settle_Up_Hit_WhenCloseAboveBase()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 10);
- int postID;
- int stockID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- stockID = stock.ID;
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stockID, due, close: 11000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(1, result.Considered);
- Assert.AreEqual(1, result.Hits);
- }
- using (var v = TestDb.Create())
- {
- var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID);
- Assert.AreEqual(PredictionStatus.Hit, p.Status);
- Assert.AreEqual(11000m, p.SettledPrice);
- Assert.IsNotNull(p.SettledAt);
- }
- }
- [TestMethod]
- public async Task Settle_Up_Miss_WhenCloseEqualBase()
- {
- // 동가 = Miss
- var code = NextCode();
- var due = new DateOnly(2026, 6, 10);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 10000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(1, result.Misses);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Miss, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- [TestMethod]
- public async Task Settle_Down_Hit_WhenCloseBelowBase()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 11);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 9000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Down, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- [TestMethod]
- public async Task Settle_Down_Miss_WhenCloseAboveBase()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 11);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 11000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Down, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Miss, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- [TestMethod]
- public async Task Settle_WithTarget_Hit_WhenReached()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 12);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 12500);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due, target: 12000m);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- // ── Void ──────────────────────────────────────────────────
- [TestMethod]
- public async Task Settle_Void_WhenNoPrice()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 13);
- int postID;
- using (var db = TestDb.Create())
- {
- await CreateStockAsync(db, code); // 시세 미시드
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(1, result.Voids);
- }
- using var v = TestDb.Create();
- var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID);
- Assert.AreEqual(PredictionStatus.Void, p.Status);
- Assert.IsNull(p.SettledPrice, "Void 는 종가 미기록");
- }
- [TestMethod]
- public async Task Settle_Void_WhenDelisted_EvenWithPrice()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 13);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code, active: false); // 상폐
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 12000); // 종가 있어도
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Void, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- [TestMethod]
- public async Task Settle_Void_WhenTradingHalted()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 13);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code, status: TradingStatus.Halted); // 거래정지
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 12000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Void, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- // ── 삭제글 채점 + 직전거래일 대체 ──────────────────────────
- [TestMethod]
- public async Task Settle_DeletedPost_StillScored()
- {
- // 삭제 회피 어뷰징 차단 — IsDeleted 여도 채점된다
- var code = NextCode();
- var due = new DateOnly(2026, 6, 16);
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code, deleted: true);
- await AddCloseAsync(db, stock.ID, due, close: 11000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(1, result.Considered, "삭제글도 채점 대상");
- Assert.AreEqual(1, result.Hits);
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Hit, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- [TestMethod]
- public async Task Settle_UsesNearestPriorTradingDay_WhenDueDateMissing()
- {
- // DueDate 당일 시세 없음(휴장) → 직전 거래일 종가로 채점
- var code = NextCode();
- var due = new DateOnly(2026, 6, 17); // 당일 시세 없음
- var prior = new DateOnly(2026, 6, 16); // 직전 거래일 종가 존재
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, prior, close: 11500);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- using var v = TestDb.Create();
- var p = await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID);
- Assert.AreEqual(PredictionStatus.Hit, p.Status);
- Assert.AreEqual(11500m, p.SettledPrice, "직전 거래일 종가로 채점");
- }
- [TestMethod]
- public async Task Settle_SkipsPendingWithFutureDueDate()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 30); // 미래
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (var memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, new DateOnly(2026, 6, 20), close: 11000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, new DateOnly(2026, 6, 20), default);
- Assert.AreEqual(0, result.Considered, "DueDate 가 asOf 이후면 미채점");
- }
- using var v = TestDb.Create();
- Assert.AreEqual(PredictionStatus.Pending, (await v.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID)).Status);
- }
- // ── 트랙레코드 재집계 ─────────────────────────────────────
- [TestMethod]
- public async Task Settle_RecomputesTrackRecord_HitRateAndStreak()
- {
- // 한 회원이 3건 예측: Hit, Miss, Hit (동일 due) → 표본 2? 아니: 3건 모두 방향 채점.
- var code = NextCode();
- var due = new DateOnly(2026, 6, 18);
- int memberID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 11000); // Up 이면 Hit, Down 이면 Miss
- var member = Member.Create($"pred-{Guid.NewGuid():N}@antooza.test");
- await db.Member.AddAsync(member);
- await db.SaveChangesAsync(default);
- memberID = member.ID;
- var group = new BoardGroup { Code = $"g{Guid.NewGuid():N}"[..12], Name = "g" };
- await db.BoardGroup.AddAsync(group);
- await db.SaveChangesAsync(default);
- var board = new Board { BoardGroupID = group.ID, Code = $"b{Guid.NewGuid():N}"[..12], Name = "b", IsActive = true };
- await db.Board.AddAsync(board);
- await db.SaveChangesAsync(default);
- // 3 posts: Up(Hit), Down(Miss), Up(Hit)
- foreach (var dir in new[] { PredictionDirection.Up, PredictionDirection.Down, PredictionDirection.Up })
- {
- var post = new Post { BoardID = board.ID, MemberID = memberID, StockCode = code, Subject = "s", Content = "c", Name = member.Name, SID = member.SID, Email = member.Email };
- await db.Post.AddAsync(post);
- await db.SaveChangesAsync(default);
- await AddPredictionAsync(db, post.ID, memberID, code, dir, basePrice: 10000m, dueDate: due);
- }
- }
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(3, result.Considered);
- Assert.AreEqual(1, result.MembersRecomputed);
- }
- using var v = TestDb.Create();
- var tr = await v.MemberTrackRecord.AsNoTracking().SingleAsync(c => c.MemberID == memberID);
- Assert.AreEqual(3, tr.Predictions);
- Assert.AreEqual(2, tr.Hits);
- Assert.AreEqual(1, tr.Misses);
- Assert.AreEqual(0, tr.Voids);
- Assert.AreEqual(66.67m, tr.HitRate, "2/3 = 66.67");
- // ID 순 시퀀스 H(Up) M(Down) H(Up) → 최장 연속 Hit=1, 현재 연속(마지막 H)=1
- Assert.AreEqual(1, tr.BestStreak);
- Assert.AreEqual(1, tr.CurrentStreak);
- }
- [TestMethod]
- public async Task Settle_Idempotent_ReRunDoesNotDoubleCount()
- {
- var code = NextCode();
- var due = new DateOnly(2026, 6, 19);
- int memberID;
- int postID;
- using (var db = TestDb.Create())
- {
- var stock = await CreateStockAsync(db, code);
- (memberID, postID) = await CreateMemberWithPostAsync(db, code);
- await AddCloseAsync(db, stock.ID, due, close: 11000);
- await AddPredictionAsync(db, postID, memberID, code, PredictionDirection.Up, basePrice: 10000m, dueDate: due);
- }
- using (var db = TestDb.Create())
- {
- await PredictionSettlementEngine.RunAsync(db, due, default);
- }
- // 재실행 — 이미 확정된 예측은 다시 채점되지 않고 트랙레코드도 그대로
- using (var db = TestDb.Create())
- {
- var (result, _) = await PredictionSettlementEngine.RunAsync(db, due, default);
- Assert.AreEqual(0, result.Considered, "이미 채점된 예측은 재대상 아님");
- }
- using var v = TestDb.Create();
- var tr = await v.MemberTrackRecord.AsNoTracking().SingleAsync(c => c.MemberID == memberID);
- Assert.AreEqual(1, tr.Predictions);
- Assert.AreEqual(1, tr.Hits);
- }
- }
|