| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- using Application.Helpers;
- using Domain.Entities.Paper;
- using Domain.Entities.Paper.ValueObject;
- using Infrastructure.Persistence;
- namespace Application.Tests;
- /// <summary>
- /// 모의투자 리더보드 지표 계산(PaperLeaderboardMetrics, Redis 미의존) + 트랙레코드 핸들러 검증 (d4 M3 리더보드 백엔드 구현).
- /// Redis 캐시(PaperLeaderboardCache)는 실행 중 Redis 가 필요하므로, 순위 산출의 진실 원천인 지표/정렬 계층을 직접 검증한다.
- /// LocalDB(antooza_test) 공유 — 각 테스트는 고유 회원/종목코드(9600xx)로 격리한다.
- /// </summary>
- [TestClass]
- public sealed class PaperLeaderboardTests
- {
- // 계좌 + N개 일별 스냅샷 시드. nav 리스트로 (nav-1)*10000=CumReturnBp, fillCount 지정.
- private static async Task<PaperAccount> 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");
- }
- }
|