|
@@ -0,0 +1,208 @@
|
|
|
|
|
+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");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|