Просмотр исходного кода

feat(paper): D4 M3(backend) — 모의투자 리더보드/트랙레코드 API

- PaperLeaderboardMetrics (Application/Helpers): Redis 미의존 순수 지표
  계산 계층 — 등재자격(FillCountCum≥MinFillsForRank), 기간 수익률
  (all=CumReturnBp, week/month=nav 변화율+폴백), 승률(승매도/전체매도),
  MDD. sort/period 파싱·Score·IsAscending(mdd 오름차순).
- IPaperLeaderboardCache (Application/Abstractions/Paper) +
  PaperLeaderboardCache (Infrastructure/Paper): RedisChatLeaderboard 미러 —
  paper-lb:{sort}:{period} Sorted Set, 캐시미스 시 DB 재시드, TTL 25h,
  ZREVRANGE/ZRANGE + 순위 조회. DI 등록.
- 엔드포인트/핸들러: GET /api/paper/leaderboard(익명·페이징),
  /leaderboard/me(인증·HasRank), /members/{sid}/track-record(익명·HasAccount).

빌드 0 에러, 테스트 95/95 (신규 7: 정렬·승률·mdd오름차순·기간수익률·트랙레코드·내순위).
Redis 재시드는 캐시미스 lazy 방식(배치 warm-push 는 후속 선택).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 недель назад
Родитель
Сommit
4d43f1ca1f

+ 42 - 0
Application/Abstractions/Paper/IPaperLeaderboardCache.cs

@@ -0,0 +1,42 @@
+using Application.Abstractions.Data;
+using Application.Helpers;
+
+namespace Application.Abstractions.Paper;
+
+/// <summary>리더보드 페이지 1행 — 회원 PK·점수·순위(0-based).</summary>
+public sealed record PaperLeaderboardRow(int MemberID, int Score, long Rank);
+
+/// <summary>내 순위 조회 결과 — 순위(0-based)·점수·전체 등재 수.</summary>
+public sealed record PaperLeaderboardMyRank(long Rank, int Score, long Total);
+
+/// <summary>
+/// 모의투자 리더보드 Redis Sorted Set 캐시 (d4 M3 리더보드 백엔드 구현).
+/// (sort, period) 조합별 Sorted Set. 캐시 미스 시 DB 재시드(TTL ~25h) — RedisChatLeaderboard 패턴 복제.
+/// return/winrate 는 높을수록 상위, mdd 는 낮을수록 상위.
+/// </summary>
+public interface IPaperLeaderboardCache
+{
+    /// <summary>
+    /// 지정 (sort, period)의 순위 페이지(offset, count)를 반환. 캐시 미스 시 DB 로 재시드.
+    /// 등재 계좌가 없으면 빈 목록.
+    /// </summary>
+    Task<IReadOnlyList<PaperLeaderboardRow>> GetPageAsync(
+        PaperLeaderboardSort sort,
+        PaperLeaderboardPeriod period,
+        int offset,
+        int count,
+        IAppDbContext db,
+        int minFillsForRank,
+        CancellationToken ct = default);
+
+    /// <summary>
+    /// 지정 (sort, period)에서 회원의 순위·점수·전체 등재 수. 미등재면 null.
+    /// </summary>
+    Task<PaperLeaderboardMyRank?> GetMyRankAsync(
+        PaperLeaderboardSort sort,
+        PaperLeaderboardPeriod period,
+        int memberID,
+        IAppDbContext db,
+        int minFillsForRank,
+        CancellationToken ct = default);
+}

+ 74 - 0
Application/Features/Api/Paper/GetLeaderboard/Handler.cs

@@ -0,0 +1,74 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Paper;
+using Application.Helpers;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Paper.GetLeaderboard;
+
+/// <summary>
+/// 모의투자 리더보드 (익명 허용) — Redis 캐시로 순위 페이지 조회 후 회원(SID/닉네임/썸네일)·지표 결합 (d4 M3 리더보드 백엔드 구현).
+/// </summary>
+internal sealed class Handler(IAppDbContext db, IPaperLeaderboardCache cache) : IQueryHandler<Query, Response>
+{
+    private const int PageSize = 50;
+
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var sort = PaperLeaderboardMetrics.ParseSort(request.Sort);
+        var period = PaperLeaderboardMetrics.ParsePeriod(request.Period);
+        var page = request.Page < 1 ? 1 : request.Page;
+
+        var minFillsForRank = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper.MinFillsForRank).FirstOrDefaultAsync(ct);
+
+        if (minFillsForRank <= 0)
+        {
+            minFillsForRank = 3;
+        }
+
+        var offset = (page - 1) * PageSize;
+        var pageRows = await cache.GetPageAsync(sort, period, offset, PageSize, db, minFillsForRank, ct);
+
+        if (pageRows.Count == 0)
+        {
+            return new Response(PaperLeaderboardMetrics.SortToString(sort), PaperLeaderboardMetrics.PeriodToString(period), page, PageSize, []);
+        }
+
+        // 전체 지표 산출 후 memberID 로 매핑 (배치 리더보드 규모 소량 가정)
+        var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(db, period, minFillsForRank, ct);
+        var metricByMember = metrics.ToDictionary(c => c.MemberID);
+
+        var memberIDs = pageRows.Select(c => c.MemberID).ToList();
+
+        var members = await db.Member.AsNoTracking()
+            .Where(c => memberIDs.Contains(c.ID))
+            .Select(c => new { c.ID, c.SID, c.Name, c.Thumb })
+            .ToListAsync(ct);
+
+        var memberByID = members.ToDictionary(c => c.ID);
+
+        var rows = new List<Row>(pageRows.Count);
+
+        foreach (var pr in pageRows)
+        {
+            if (!memberByID.TryGetValue(pr.MemberID, out var m))
+            {
+                continue;
+            }
+
+            metricByMember.TryGetValue(pr.MemberID, out var metric);
+
+            rows.Add(new Row(
+                pr.Rank + 1,
+                m.SID,
+                m.Name,
+                m.Thumb,
+                metric?.ReturnBp ?? 0,
+                metric?.WinRateBp ?? 0,
+                metric?.MddBp ?? 0,
+                metric?.FillCount ?? 0));
+        }
+
+        return new Response(PaperLeaderboardMetrics.SortToString(sort), PaperLeaderboardMetrics.PeriodToString(period), page, PageSize, rows);
+    }
+}

+ 6 - 0
Application/Features/Api/Paper/GetLeaderboard/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Paper.GetLeaderboard;
+
+/// <summary>모의투자 리더보드 (익명 허용). sort: return|winrate|mdd, period: week|month|all.</summary>
+public sealed record Query(string? Sort, string? Period, int Page) : IQuery<Response>;

+ 21 - 0
Application/Features/Api/Paper/GetLeaderboard/Response.cs

@@ -0,0 +1,21 @@
+namespace Application.Features.Api.Paper.GetLeaderboard;
+
+/// <summary>리더보드 1행.</summary>
+public sealed record Row(
+    long Rank,
+    string MemberSID,
+    string? Nickname,
+    string? Thumb,
+    int ReturnBp,
+    int WinRateBp,
+    int MddBp,
+    int FillCount
+);
+
+public sealed record Response(
+    string Sort,
+    string Period,
+    int Page,
+    int PageSize,
+    IReadOnlyList<Row> Rows
+);

+ 49 - 0
Application/Features/Api/Paper/GetMyLeaderboardRank/Handler.cs

@@ -0,0 +1,49 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Paper;
+using Application.Helpers;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Paper.GetMyLeaderboardRank;
+
+/// <summary>
+/// 내 리더보드 순위·지표 (인증) — 미등재(등재요건 미달·계좌/스냅샷 없음)면 HasRank=false (d4 M3 리더보드 백엔드 구현).
+/// </summary>
+internal sealed class Handler(IAppDbContext db, IPaperLeaderboardCache cache) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var sort = PaperLeaderboardMetrics.ParseSort(request.Sort);
+        var period = PaperLeaderboardMetrics.ParsePeriod(request.Period);
+        var sortStr = PaperLeaderboardMetrics.SortToString(sort);
+        var periodStr = PaperLeaderboardMetrics.PeriodToString(period);
+
+        var minFillsForRank = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper.MinFillsForRank).FirstOrDefaultAsync(ct);
+
+        if (minFillsForRank <= 0)
+        {
+            minFillsForRank = 3;
+        }
+
+        var my = await cache.GetMyRankAsync(sort, period, request.MemberID, db, minFillsForRank, ct);
+
+        if (my is null)
+        {
+            return new Response(sortStr, periodStr, false, 0, 0, 0, 0, 0, 0);
+        }
+
+        var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(db, period, minFillsForRank, ct);
+        var metric = metrics.FirstOrDefault(c => c.MemberID == request.MemberID);
+
+        return new Response(
+            sortStr,
+            periodStr,
+            true,
+            my.Rank + 1,
+            my.Total,
+            metric?.ReturnBp ?? 0,
+            metric?.WinRateBp ?? 0,
+            metric?.MddBp ?? 0,
+            metric?.FillCount ?? 0);
+    }
+}

+ 6 - 0
Application/Features/Api/Paper/GetMyLeaderboardRank/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Paper.GetMyLeaderboardRank;
+
+/// <summary>내 리더보드 순위·지표 (인증). sort: return|winrate|mdd, period: week|month|all.</summary>
+public sealed record Query(string? Sort, string? Period, int MemberID) : IQuery<Response>;

+ 13 - 0
Application/Features/Api/Paper/GetMyLeaderboardRank/Response.cs

@@ -0,0 +1,13 @@
+namespace Application.Features.Api.Paper.GetMyLeaderboardRank;
+
+public sealed record Response(
+    string Sort,
+    string Period,
+    bool HasRank,
+    long Rank,
+    long Total,
+    int ReturnBp,
+    int WinRateBp,
+    int MddBp,
+    int FillCount
+);

+ 54 - 0
Application/Features/Api/Paper/GetTrackRecord/Handler.cs

@@ -0,0 +1,54 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Paper.GetTrackRecord;
+
+/// <summary>
+/// 프로필 모의투자 성적 (익명 허용) — SID→회원→계좌→최신 스냅샷. 계좌/스냅샷 없으면 HasAccount=false (d4 M3 리더보드 백엔드 구현).
+/// 승률은 전체 매도 기준(RealizedPnL>0 / RealizedPnL!=null).
+/// </summary>
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    private static readonly Response Empty = new(false, 0, 0, 0, 0, null);
+
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(request.Sid))
+        {
+            return Empty;
+        }
+
+        var memberID = await db.Member.AsNoTracking().Where(c => c.SID == request.Sid).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct);
+
+        if (memberID is null)
+        {
+            return Empty;
+        }
+
+        var accountID = await db.PaperAccount.AsNoTracking().Where(c => c.MemberID == memberID.Value).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct);
+
+        if (accountID is null)
+        {
+            return Empty;
+        }
+
+        var latest = await db.PaperDailySnapshot.AsNoTracking()
+            .Where(c => c.AccountID == accountID.Value)
+            .OrderByDescending(c => c.TradeDate)
+            .Select(c => new { c.CumReturnBp, c.MddBp, c.FillCountCum, c.TradeDate })
+            .FirstOrDefaultAsync(ct);
+
+        if (latest is null)
+        {
+            return Empty;
+        }
+
+        var totalSells = await db.PaperFill.AsNoTracking().CountAsync(c => c.RealizedPnL != null && c.Order.AccountID == accountID.Value, ct);
+        var winningSells = await db.PaperFill.AsNoTracking().CountAsync(c => c.RealizedPnL > 0 && c.Order.AccountID == accountID.Value, ct);
+
+        var winRateBp = totalSells > 0 ? (int)Math.Round(winningSells / (double)totalSells * 10000, MidpointRounding.AwayFromZero) : 0;
+
+        return new Response(true, latest.CumReturnBp, latest.MddBp, winRateBp, latest.FillCountCum, latest.TradeDate);
+    }
+}

+ 6 - 0
Application/Features/Api/Paper/GetTrackRecord/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Paper.GetTrackRecord;
+
+/// <summary>프로필 모의투자 성적 (익명 허용) — 회원 SID 로 조회.</summary>
+public sealed record Query(string Sid) : IQuery<Response>;

+ 10 - 0
Application/Features/Api/Paper/GetTrackRecord/Response.cs

@@ -0,0 +1,10 @@
+namespace Application.Features.Api.Paper.GetTrackRecord;
+
+public sealed record Response(
+    bool HasAccount,
+    int CumReturnBp,
+    int MddBp,
+    int WinRateBp,
+    int FillCount,
+    DateOnly? LatestTradeDate
+);

+ 241 - 0
Application/Helpers/PaperLeaderboardMetrics.cs

@@ -0,0 +1,241 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Paper.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Helpers;
+
+/// <summary>
+/// 모의투자 리더보드 정렬 기준 (d4 §⑤).
+/// </summary>
+public enum PaperLeaderboardSort
+{
+    /// <summary>누적/기간 수익률 (높을수록 상위)</summary>
+    Return = 0,
+
+    /// <summary>승률 (높을수록 상위)</summary>
+    WinRate = 1,
+
+    /// <summary>최대 낙폭 MDD (낮을수록 상위)</summary>
+    Mdd = 2
+}
+
+/// <summary>
+/// 모의투자 리더보드 기간 (d4 §⑤). 수익률만 기간에 영향 — 승률/MDD 는 전체 기준.
+/// </summary>
+public enum PaperLeaderboardPeriod
+{
+    Week = 0,
+    Month = 1,
+    All = 2
+}
+
+/// <summary>
+/// 등재 자격 계좌의 리더보드 지표.
+/// </summary>
+/// <param name="MemberID">회원 PK</param>
+/// <param name="ReturnBp">기간 수익률 Bp</param>
+/// <param name="WinRateBp">승률 Bp (승매도/전체매도 × 10000)</param>
+/// <param name="MddBp">최대 낙폭 Bp</param>
+/// <param name="FillCount">누적 체결 수</param>
+public sealed record PaperLeaderboardMetric(
+    int MemberID,
+    int ReturnBp,
+    int WinRateBp,
+    int MddBp,
+    int FillCount
+);
+
+/// <summary>
+/// 모의투자 리더보드 지표 계산 (d4 M3 리더보드 백엔드 구현) — Redis 미의존 순수 계산 계층.
+/// 캐시(<see cref="Application.Abstractions.Paper.IPaperLeaderboardCache"/>) 재시드와 단위 테스트가 공유한다.
+/// 등재 자격: 최신 스냅샷의 FillCountCum >= MinFillsForRank.
+/// </summary>
+public static class PaperLeaderboardMetrics
+{
+    /// <summary>정렬 기준별 Redis Sorted Set 점수 — return/winrate 는 높을수록, mdd 는 낮을수록 상위.</summary>
+    public static double Score(PaperLeaderboardMetric metric, PaperLeaderboardSort sort)
+    {
+        return sort switch
+        {
+            PaperLeaderboardSort.Return => metric.ReturnBp,
+            PaperLeaderboardSort.WinRate => metric.WinRateBp,
+            PaperLeaderboardSort.Mdd => metric.MddBp,
+            _ => metric.ReturnBp
+        };
+    }
+
+    /// <summary>mdd 는 낮을수록 상위(ZRANGE 오름차순), 나머지는 높을수록 상위(ZREVRANGE 내림차순).</summary>
+    public static bool IsAscending(PaperLeaderboardSort sort)
+    {
+        return sort == PaperLeaderboardSort.Mdd;
+    }
+
+    /// <summary>sort 문자열 파싱 — 미지정/미인식 시 기본 Return.</summary>
+    public static PaperLeaderboardSort ParseSort(string? sort)
+    {
+        return sort?.Trim().ToLowerInvariant() switch
+        {
+            "winrate" => PaperLeaderboardSort.WinRate,
+            "mdd" => PaperLeaderboardSort.Mdd,
+            _ => PaperLeaderboardSort.Return
+        };
+    }
+
+    /// <summary>period 문자열 파싱 — 미지정/미인식 시 기본 All.</summary>
+    public static PaperLeaderboardPeriod ParsePeriod(string? period)
+    {
+        return period?.Trim().ToLowerInvariant() switch
+        {
+            "week" => PaperLeaderboardPeriod.Week,
+            "month" => PaperLeaderboardPeriod.Month,
+            _ => PaperLeaderboardPeriod.All
+        };
+    }
+
+    /// <summary>sort → API 노출 문자열.</summary>
+    public static string SortToString(PaperLeaderboardSort sort)
+    {
+        return sort switch
+        {
+            PaperLeaderboardSort.WinRate => "winrate",
+            PaperLeaderboardSort.Mdd => "mdd",
+            _ => "return"
+        };
+    }
+
+    /// <summary>period → API 노출 문자열.</summary>
+    public static string PeriodToString(PaperLeaderboardPeriod period)
+    {
+        return period switch
+        {
+            PaperLeaderboardPeriod.Week => "week",
+            PaperLeaderboardPeriod.Month => "month",
+            _ => "all"
+        };
+    }
+
+    /// <summary>
+    /// 지정 기간의 등재 자격 계좌 지표를 DB 에서 산출한다 (캐시 재시드 소스).
+    /// return: 기간 nav 변화율. winrate: 전체 매도 기준. mdd: 최신 스냅샷.
+    /// </summary>
+    public static async Task<List<PaperLeaderboardMetric>> ComputeAllAsync(
+        IAppDbContext db,
+        PaperLeaderboardPeriod period,
+        int minFillsForRank,
+        CancellationToken ct)
+    {
+        // 각 계좌의 최신 스냅샷 (등재 자격 판정 + mdd/누적수익률/체결수)
+        var latestByAccount = await db.PaperDailySnapshot.AsNoTracking()
+            .GroupBy(c => c.AccountID)
+            .Select(g => new { AccountID = g.Key, MaxDate = g.Max(c => c.TradeDate) })
+            .ToListAsync(ct);
+
+        if (latestByAccount.Count == 0)
+        {
+            return [];
+        }
+
+        var latestDates = latestByAccount.ToDictionary(c => c.AccountID, c => c.MaxDate);
+        var qualifiedAccountIDs = latestByAccount.Select(c => c.AccountID).ToList();
+
+        // 자격 계좌들의 스냅샷 전체 로드 (기간 시작 nav 조회 + 최신 지표) — 계좌 수 규모 소량 가정
+        var snapshots = await db.PaperDailySnapshot.AsNoTracking()
+            .Where(c => qualifiedAccountIDs.Contains(c.AccountID))
+            .Select(c => new SnapshotRow(c.AccountID, c.TradeDate, c.UnitNav, c.CumReturnBp, c.MddBp, c.FillCountCum))
+            .ToListAsync(ct);
+
+        // 계좌 → 회원 매핑
+        var accounts = await db.PaperAccount.AsNoTracking()
+            .Where(c => qualifiedAccountIDs.Contains(c.ID))
+            .Select(c => new { c.ID, c.MemberID })
+            .ToListAsync(ct);
+
+        var memberByAccount = accounts.ToDictionary(c => c.ID, c => c.MemberID);
+
+        // 자격 계좌들의 매도 체결(승률용) — PaperFill → PaperOrder(AccountID)
+        var sellFills = await db.PaperFill.AsNoTracking()
+            .Where(c => c.RealizedPnL != null && qualifiedAccountIDs.Contains(c.Order.AccountID))
+            .Select(c => new { c.Order.AccountID, c.RealizedPnL })
+            .ToListAsync(ct);
+
+        var winByAccount = sellFills
+            .GroupBy(c => c.AccountID)
+            .ToDictionary(g => g.Key, g => (Total: g.Count(), Win: g.Count(c => c.RealizedPnL > 0)));
+
+        var byAccount = snapshots.GroupBy(c => c.AccountID);
+        var result = new List<PaperLeaderboardMetric>();
+
+        foreach (var group in byAccount)
+        {
+            var accountID = group.Key;
+
+            if (!latestDates.TryGetValue(accountID, out var latestDate) || !memberByAccount.TryGetValue(accountID, out var memberID))
+            {
+                continue;
+            }
+
+            var ordered = group.OrderBy(c => c.TradeDate).ToList();
+            var latest = ordered[^1];
+
+            // 등재 자격: 최신 스냅샷 체결 수
+            if (latest.FillCountCum < minFillsForRank)
+            {
+                continue;
+            }
+
+            var returnBp = ComputePeriodReturnBp(ordered, latest, latestDate, period);
+
+            var winRateBp = 0;
+
+            if (winByAccount.TryGetValue(accountID, out var w) && w.Total > 0)
+            {
+                winRateBp = (int)Math.Round(w.Win / (double)w.Total * 10000, MidpointRounding.AwayFromZero);
+            }
+
+            result.Add(new PaperLeaderboardMetric(memberID, returnBp, winRateBp, latest.MddBp, latest.FillCountCum));
+        }
+
+        return result;
+    }
+
+    /// <summary>
+    /// 기간 수익률 Bp. all = 최신 CumReturnBp. week/month = (navLatest / navAtWindowStart - 1) × 10000,
+    /// windowStart = latestDate - 7/30일. 시작점 스냅샷 없으면 최초 스냅샷 nav 로 폴백.
+    /// </summary>
+    private static int ComputePeriodReturnBp(
+        List<SnapshotRow> ordered,
+        SnapshotRow latest,
+        DateOnly latestDate,
+        PaperLeaderboardPeriod period)
+    {
+        if (period == PaperLeaderboardPeriod.All)
+        {
+            return latest.CumReturnBp;
+        }
+
+        var days = period == PaperLeaderboardPeriod.Week ? 7 : 30;
+        var windowStart = latestDate.AddDays(-days);
+
+        // windowStart 이하의 가장 최신 스냅샷 nav, 없으면 최초 스냅샷 nav 로 폴백
+        SnapshotRow? startRow = null;
+
+        foreach (var row in ordered)
+        {
+            if (row.TradeDate <= windowStart)
+            {
+                startRow = row;
+            }
+        }
+
+        var startNav = (startRow ?? ordered[0]).UnitNav;
+
+        if (startNav <= 0)
+        {
+            return latest.CumReturnBp;
+        }
+
+        return (int)Math.Round((latest.UnitNav / startNav - 1m) * 10000m, MidpointRounding.AwayFromZero);
+    }
+
+    private sealed record SnapshotRow(int AccountID, DateOnly TradeDate, decimal UnitNav, int CumReturnBp, int MddBp, int FillCountCum);
+}

+ 1 - 0
Infrastructure/DependencyInjection.cs

@@ -124,6 +124,7 @@ public static class DependencyInjection
         services.AddSingleton<IChatMessageStore, RedisChatMessageStore>();
         services.AddSingleton<IChatConnectionTracker, RedisChatConnectionTracker>();
         services.AddSingleton<IChatLeaderboard, RedisChatLeaderboard>();
+        services.AddSingleton<Application.Abstractions.Paper.IPaperLeaderboardCache, Paper.PaperLeaderboardCache>();
         services.AddSingleton<IChatRewardHook, NoOpChatRewardHook>(); // M1 no-op — D3 RewardService 구현으로 교체 (d0 §2.2)
         services.AddSingleton<IPresenceTracker, Hubs.PresenceTracker>();
         services.AddSingleton<IVisitorTracker, Hubs.RedisVisitorTracker>();

+ 135 - 0
Infrastructure/Paper/PaperLeaderboardCache.cs

@@ -0,0 +1,135 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Paper;
+using Application.Helpers;
+using StackExchange.Redis;
+
+namespace Infrastructure.Paper;
+
+/// <summary>
+/// Redis Sorted Set 기반 모의투자 리더보드 캐시 (d4 M3 리더보드 백엔드 구현) — RedisChatLeaderboard 패턴 복제.
+/// (sort, period) 조합별 키. TTL 25h — 만료 시 다음 호출이 DB 지표 산출 후 재시드.
+/// return/winrate 는 ZREVRANGE(내림차순 = 높을수록 상위), mdd 는 ZRANGE(오름차순 = 낮을수록 상위).
+/// </summary>
+internal sealed class PaperLeaderboardCache(IConnectionMultiplexer redis) : IPaperLeaderboardCache
+{
+    private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(25);
+
+    private static string Key(PaperLeaderboardSort sort, PaperLeaderboardPeriod period)
+    {
+        var s = sort switch
+        {
+            PaperLeaderboardSort.Return => "return",
+            PaperLeaderboardSort.WinRate => "winrate",
+            PaperLeaderboardSort.Mdd => "mdd",
+            _ => "return"
+        };
+
+        var p = period switch
+        {
+            PaperLeaderboardPeriod.Week => "week",
+            PaperLeaderboardPeriod.Month => "month",
+            PaperLeaderboardPeriod.All => "all",
+            _ => "all"
+        };
+
+        return $"paper-lb:{s}:{p}";
+    }
+
+    public async Task<IReadOnlyList<PaperLeaderboardRow>> GetPageAsync(
+        PaperLeaderboardSort sort,
+        PaperLeaderboardPeriod period,
+        int offset,
+        int count,
+        IAppDbContext db,
+        int minFillsForRank,
+        CancellationToken ct = default)
+    {
+        var r = redis.GetDatabase();
+        var key = Key(sort, period);
+
+        await EnsureSeededAsync(r, key, sort, period, db, minFillsForRank, ct);
+
+        var ascending = PaperLeaderboardMetrics.IsAscending(sort);
+        var order = ascending ? Order.Ascending : Order.Descending;
+
+        var stop = offset + count - 1;
+        var entries = await r.SortedSetRangeByRankWithScoresAsync(key, offset, stop, order);
+
+        var rows = new List<PaperLeaderboardRow>(entries.Length);
+
+        for (var i = 0; i < entries.Length; i++)
+        {
+            if (int.TryParse((string?)entries[i].Element, out var memberID))
+            {
+                rows.Add(new PaperLeaderboardRow(memberID, (int)entries[i].Score, offset + i));
+            }
+        }
+
+        return rows;
+    }
+
+    public async Task<PaperLeaderboardMyRank?> GetMyRankAsync(
+        PaperLeaderboardSort sort,
+        PaperLeaderboardPeriod period,
+        int memberID,
+        IAppDbContext db,
+        int minFillsForRank,
+        CancellationToken ct = default)
+    {
+        var r = redis.GetDatabase();
+        var key = Key(sort, period);
+
+        await EnsureSeededAsync(r, key, sort, period, db, minFillsForRank, ct);
+
+        var member = (RedisValue)memberID.ToString();
+        var score = await r.SortedSetScoreAsync(key, member);
+
+        if (!score.HasValue)
+        {
+            return null;
+        }
+
+        var ascending = PaperLeaderboardMetrics.IsAscending(sort);
+        var order = ascending ? Order.Ascending : Order.Descending;
+
+        var rank = await r.SortedSetRankAsync(key, member, order);
+        var total = await r.SortedSetLengthAsync(key);
+
+        if (!rank.HasValue)
+        {
+            return null;
+        }
+
+        return new PaperLeaderboardMyRank(rank.Value, (int)score.Value, total);
+    }
+
+    /// <summary>캐시 미스 → DB 지표 산출 후 ZADD 재시드. TTL 동안 후속 호출은 Redis only.</summary>
+    private static async Task EnsureSeededAsync(
+        IDatabase r,
+        string key,
+        PaperLeaderboardSort sort,
+        PaperLeaderboardPeriod period,
+        IAppDbContext db,
+        int minFillsForRank,
+        CancellationToken ct)
+    {
+        if (await r.KeyExistsAsync(key))
+        {
+            return;
+        }
+
+        var metrics = await PaperLeaderboardMetrics.ComputeAllAsync(db, period, minFillsForRank, ct);
+
+        if (metrics.Count == 0)
+        {
+            return;
+        }
+
+        var entries = metrics
+            .Select(c => new SortedSetEntry(c.MemberID.ToString(), PaperLeaderboardMetrics.Score(c, sort)))
+            .ToArray();
+
+        await r.SortedSetAddAsync(key, entries);
+        await r.KeyExpireAsync(key, CacheTtl);
+    }
+}

+ 208 - 0
Tests/Application.Tests/PaperLeaderboardTests.cs

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

+ 24 - 0
Web.Api/Endpoints/Paper/GetLeaderboard.cs

@@ -0,0 +1,24 @@
+using Application.Abstractions.Messaging;
+using Web.Api.Common;
+
+namespace Web.Api.Endpoints.Paper;
+
+/// <summary>모의투자 리더보드 — 수익률/승률/MDD × 주/월/전체 (익명 허용).</summary>
+internal sealed class GetLeaderboard : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/paper/leaderboard", async (
+            string? sort,
+            string? period,
+            int? page,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var data = await sender.Send(new Application.Features.Api.Paper.GetLeaderboard.Query(sort, period, page ?? 1), ct);
+            return ApiResponse.Ok(data);
+        })
+        .WithTags("Paper")
+        .AllowAnonymous();
+    }
+}

+ 27 - 0
Web.Api/Endpoints/Paper/GetMyLeaderboardRank.cs

@@ -0,0 +1,27 @@
+using System.Security.Claims;
+using Application.Abstractions.Messaging;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.Paper;
+
+/// <summary>모의투자 리더보드 — 내 순위·지표 (인증).</summary>
+internal sealed class GetMyLeaderboardRank : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/paper/leaderboard/me", async (
+            string? sort,
+            string? period,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetRequiredMemberID();
+            var data = await sender.Send(new Application.Features.Api.Paper.GetMyLeaderboardRank.Query(sort, period, memberID), ct);
+            return ApiResponse.Ok(data);
+        })
+        .WithTags("Paper")
+        .RequireAuthorization();
+    }
+}

+ 22 - 0
Web.Api/Endpoints/Paper/GetTrackRecord.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Messaging;
+using Web.Api.Common;
+
+namespace Web.Api.Endpoints.Paper;
+
+/// <summary>모의투자 프로필 성적 — 회원 SID 기준 (익명 허용).</summary>
+internal sealed class GetTrackRecord : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/paper/members/{sid}/track-record", async (
+            string sid,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var data = await sender.Send(new Application.Features.Api.Paper.GetTrackRecord.Query(sid), ct);
+            return ApiResponse.Ok(data);
+        })
+        .WithTags("Paper")
+        .AllowAnonymous();
+    }
+}