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");
}
}