using Application.Helpers; using Domain.Entities.Paper; using Domain.Entities.Paper.ValueObject; using Infrastructure.Persistence; namespace Application.Tests; /// /// 모의투자 리더보드 지표 계산(PaperLeaderboardMetrics, Redis 미의존) + 트랙레코드 핸들러 검증 (d4 M3 리더보드 백엔드 구현). /// Redis 캐시(PaperLeaderboardCache)는 실행 중 Redis 가 필요하므로, 순위 산출의 진실 원천인 지표/정렬 계층을 직접 검증한다. /// LocalDB(antooza_test) 공유 — 각 테스트는 고유 회원/종목코드(9600xx)로 격리한다. /// [TestClass] public sealed class PaperLeaderboardTests { // 계좌 + N개 일별 스냅샷 시드. nav 리스트로 (nav-1)*10000=CumReturnBp, fillCount 지정. private static async Task SeedAccountWithSnapshotsAsync( AppDbContext db, int memberID, (DateOnly Date, decimal Nav, int MddBp, int FillCount)[] snapshots) { var account = PaperAccount.Create(memberID); account.Deposit(1_000_000m, 1_000_000m); await db.PaperAccount.AddAsync(account); await db.SaveChangesAsync(default); foreach (var s in snapshots) { var equity = 1_000_000m * s.Nav; var cumReturnBp = (int)Math.Round((s.Nav - 1m) * 10000m, MidpointRounding.AwayFromZero); await db.PaperDailySnapshot.AddAsync(PaperDailySnapshot.Create( account.ID, s.Date, equity, 0m, equity, s.Nav, 0, cumReturnBp, equity, s.MddBp, s.FillCount)); } await db.SaveChangesAsync(default); return account; } // 매도 체결(RealizedPnL) 시드 — 승률 계산용. private static async Task SeedSellFillAsync(AppDbContext db, int accountID, string code, decimal realizedPnL) { var order = PaperOrder.Create(accountID, code, PaperOrderSide.Sell, PaperFillRule.Close, 1, 0m, new DateOnly(2026, 6, 10), DateTime.UtcNow.AddDays(1)); await db.PaperOrder.AddAsync(order); await db.SaveChangesAsync(default); await db.PaperFill.AddAsync(PaperFill.Create(order.ID, 10000m, 1, 0m, 0m, 10000m, new DateOnly(2026, 6, 10), realizedPnL)); await db.SaveChangesAsync(default); } [TestMethod] public async Task Leaderboard_OrderedByReturnDesc_ExcludesBelowMinFills() { // 기대: return 정렬 = 수익률 내림차순, 등재요건(FillCountCum >= MinFillsForRank) 미달 계좌 제외. using var db = TestDb.Create(); var high = await TestDb.CreateMemberAsync(db); var low = await TestDb.CreateMemberAsync(db); var unqualified = await TestDb.CreateMemberAsync(db); var d = new DateOnly(2026, 6, 10); await SeedAccountWithSnapshotsAsync(db, high, [(d, 1.20m, 500, 5)]); // +2000bp, 체결 5 await SeedAccountWithSnapshotsAsync(db, low, [(d, 1.05m, 200, 3)]); // +500bp, 체결 3 await SeedAccountWithSnapshotsAsync(db, unqualified, [(d, 1.50m, 100, 2)]); // 체결 2 < 3 → 제외 using var verify = TestDb.Create(); var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.All, 3, default); var ranked = metrics .OrderByDescending(c => PaperLeaderboardMetrics.Score(c, PaperLeaderboardSort.Return)) .ToList(); Assert.IsFalse(metrics.Any(c => c.MemberID == unqualified), "체결 2회 계좌는 등재 제외"); Assert.AreEqual(high, ranked[0].MemberID, "수익률 최상위"); Assert.AreEqual(2000, ranked[0].ReturnBp); Assert.AreEqual(low, ranked[1].MemberID); Assert.AreEqual(500, ranked[1].ReturnBp); } [TestMethod] public async Task Winrate_Metric_IsWinningSellsOverTotalSells() { // 기대: 승률 Bp = 승매도(RealizedPnL>0) / 전체매도(RealizedPnL!=null) × 10000. 3/4 = 7500bp. using var db = TestDb.Create(); var m = await TestDb.CreateMemberAsync(db); var d = new DateOnly(2026, 6, 10); var account = await SeedAccountWithSnapshotsAsync(db, m, [(d, 1.10m, 300, 4)]); await SeedSellFillAsync(db, account.ID, "960001", 100m); // 승 await SeedSellFillAsync(db, account.ID, "960002", 50m); // 승 await SeedSellFillAsync(db, account.ID, "960003", 10m); // 승 await SeedSellFillAsync(db, account.ID, "960004", -30m); // 패 using var verify = TestDb.Create(); var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.All, 3, default); var metric = metrics.Single(c => c.MemberID == m); Assert.AreEqual(7500, metric.WinRateBp, "3승/4매도 = 75% = 7500bp"); } [TestMethod] public async Task MddSort_IsAscending_LowestMddRankedFirst() { // 기대: mdd 정렬은 오름차순(낮을수록 상위). IsAscending(Mdd)=true. using var db = TestDb.Create(); var lowMdd = await TestDb.CreateMemberAsync(db); var highMdd = await TestDb.CreateMemberAsync(db); var d = new DateOnly(2026, 6, 10); await SeedAccountWithSnapshotsAsync(db, lowMdd, [(d, 1.10m, 100, 3)]); // mdd 100 await SeedAccountWithSnapshotsAsync(db, highMdd, [(d, 1.10m, 900, 3)]); // mdd 900 Assert.IsTrue(PaperLeaderboardMetrics.IsAscending(PaperLeaderboardSort.Mdd), "mdd 는 오름차순"); Assert.IsFalse(PaperLeaderboardMetrics.IsAscending(PaperLeaderboardSort.Return)); using var verify = TestDb.Create(); var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.All, 3, default); var ranked = metrics .OrderBy(c => PaperLeaderboardMetrics.Score(c, PaperLeaderboardSort.Mdd)) .ToList(); Assert.AreEqual(lowMdd, ranked[0].MemberID, "MDD 가 낮은 계좌가 상위"); Assert.AreEqual(100, ranked[0].MddBp); } [TestMethod] public async Task PeriodReturn_Week_UsesNavAtWindowStart() { // 기대: 주간 수익률 = navLatest/navAtWindowStart-1. windowStart(latest-7일) 이하 최신 스냅샷 nav 기준. using var db = TestDb.Create(); var m = await TestDb.CreateMemberAsync(db); // latest 6/15 nav 1.20, 6/8(=latest-7) nav 1.00 → 주간 (1.20/1.00-1)=2000bp. 전체는 (1.20-1)=2000bp 이지만 // 6/1 nav 0.80 을 두어 전체(누적 nav 기준)와 주간(6/8 기준)이 다른 값이 되도록 구성. await SeedAccountWithSnapshotsAsync(db, m, [ (new DateOnly(2026, 6, 1), 0.80m, 0, 3), (new DateOnly(2026, 6, 8), 1.00m, 0, 3), (new DateOnly(2026, 6, 15), 1.20m, 0, 3) ]); using var verify = TestDb.Create(); var weekly = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.Week, 3, default); var all = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.All, 3, default); Assert.AreEqual(2000, weekly.Single(c => c.MemberID == m).ReturnBp, "6/15 nav1.20 / 6/8 nav1.00 = +2000bp"); Assert.AreEqual(2000, all.Single(c => c.MemberID == m).ReturnBp, "전체 = 최신 CumReturnBp (nav1.20)"); } [TestMethod] public async Task TrackRecord_ReturnsMemberMetrics() { // 기대: SID → 계좌 → 최신 스냅샷 지표 + 승률 반환. using var db = TestDb.Create(); var m = await TestDb.CreateMemberAsync(db); var sid = (await db.Member.FindAsync(m))!.SID; var d = new DateOnly(2026, 6, 12); var account = await SeedAccountWithSnapshotsAsync(db, m, [(d, 1.15m, 400, 5)]); await SeedSellFillAsync(db, account.ID, "960010", 100m); // 승 await SeedSellFillAsync(db, account.ID, "960011", -50m); // 패 using var verify = TestDb.Create(); var handler = new Features.Api.Paper.GetTrackRecord.Handler(verify); var res = await handler.Handle(new Features.Api.Paper.GetTrackRecord.Query(sid), default); Assert.IsTrue(res.HasAccount); Assert.AreEqual(1500, res.CumReturnBp); Assert.AreEqual(400, res.MddBp); Assert.AreEqual(5000, res.WinRateBp, "1승/2매도 = 5000bp"); Assert.AreEqual(5, res.FillCount); Assert.AreEqual(d, res.LatestTradeDate); } [TestMethod] public async Task TrackRecord_UnknownOrNoAccountSid_ReturnsHasAccountFalse() { // 기대: 존재하지 않는 SID 또는 계좌/스냅샷 없는 회원 → HasAccount=false. using var db = TestDb.Create(); var handler = new Features.Api.Paper.GetTrackRecord.Handler(db); var unknown = await handler.Handle(new Features.Api.Paper.GetTrackRecord.Query("nosuchid"), default); Assert.IsFalse(unknown.HasAccount, "미존재 SID"); // 계좌 없는 회원 var m = await TestDb.CreateMemberAsync(db); var sid = (await db.Member.FindAsync(m))!.SID; var noAccount = await handler.Handle(new Features.Api.Paper.GetTrackRecord.Query(sid), default); Assert.IsFalse(noAccount.HasAccount, "계좌 미개설 회원"); } [TestMethod] public async Task MyRank_RankedMemberHasScore_UnrankedIsExcluded() { // 기대: 등재 회원은 지표 산출 대상에 포함, 미달 회원은 제외 (내 순위 캐시가 참조하는 산출 계층). using var db = TestDb.Create(); var ranked = await TestDb.CreateMemberAsync(db); var unranked = await TestDb.CreateMemberAsync(db); var d = new DateOnly(2026, 6, 10); await SeedAccountWithSnapshotsAsync(db, ranked, [(d, 1.30m, 200, 4)]); // 체결 4 >= 3 await SeedAccountWithSnapshotsAsync(db, unranked, [(d, 1.10m, 100, 1)]); // 체결 1 < 3 using var verify = TestDb.Create(); var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(verify, PaperLeaderboardPeriod.All, 3, default); Assert.IsTrue(metrics.Any(c => c.MemberID == ranked), "등재 회원은 지표에 존재 → HasRank"); Assert.IsFalse(metrics.Any(c => c.MemberID == unranked), "미달 회원은 제외 → HasRank=false"); } }