PaperLeaderboardMetrics.cs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. using Application.Abstractions.Data;
  2. using Domain.Entities.Paper.ValueObject;
  3. using Microsoft.EntityFrameworkCore;
  4. namespace Application.Helpers;
  5. /// <summary>
  6. /// 모의투자 리더보드 정렬 기준 (d4 §⑤).
  7. /// </summary>
  8. public enum PaperLeaderboardSort
  9. {
  10. /// <summary>누적/기간 수익률 (높을수록 상위)</summary>
  11. Return = 0,
  12. /// <summary>승률 (높을수록 상위)</summary>
  13. WinRate = 1,
  14. /// <summary>최대 낙폭 MDD (낮을수록 상위)</summary>
  15. Mdd = 2
  16. }
  17. /// <summary>
  18. /// 모의투자 리더보드 기간 (d4 §⑤). 수익률만 기간에 영향 — 승률/MDD 는 전체 기준.
  19. /// </summary>
  20. public enum PaperLeaderboardPeriod
  21. {
  22. Week = 0,
  23. Month = 1,
  24. All = 2
  25. }
  26. /// <summary>
  27. /// 등재 자격 계좌의 리더보드 지표.
  28. /// </summary>
  29. /// <param name="MemberID">회원 PK</param>
  30. /// <param name="ReturnBp">기간 수익률 Bp</param>
  31. /// <param name="WinRateBp">승률 Bp (승매도/전체매도 × 10000)</param>
  32. /// <param name="MddBp">최대 낙폭 Bp</param>
  33. /// <param name="FillCount">누적 체결 수</param>
  34. public sealed record PaperLeaderboardMetric(
  35. int MemberID,
  36. int ReturnBp,
  37. int WinRateBp,
  38. int MddBp,
  39. int FillCount
  40. );
  41. /// <summary>
  42. /// 모의투자 리더보드 지표 계산 (d4 M3 리더보드 백엔드 구현) — Redis 미의존 순수 계산 계층.
  43. /// 캐시(<see cref="Application.Abstractions.Paper.IPaperLeaderboardCache"/>) 재시드와 단위 테스트가 공유한다.
  44. /// 등재 자격: 최신 스냅샷의 FillCountCum >= MinFillsForRank.
  45. /// </summary>
  46. public static class PaperLeaderboardMetrics
  47. {
  48. /// <summary>정렬 기준별 Redis Sorted Set 점수 — return/winrate 는 높을수록, mdd 는 낮을수록 상위.</summary>
  49. public static double Score(PaperLeaderboardMetric metric, PaperLeaderboardSort sort)
  50. {
  51. return sort switch
  52. {
  53. PaperLeaderboardSort.Return => metric.ReturnBp,
  54. PaperLeaderboardSort.WinRate => metric.WinRateBp,
  55. PaperLeaderboardSort.Mdd => metric.MddBp,
  56. _ => metric.ReturnBp
  57. };
  58. }
  59. /// <summary>mdd 는 낮을수록 상위(ZRANGE 오름차순), 나머지는 높을수록 상위(ZREVRANGE 내림차순).</summary>
  60. public static bool IsAscending(PaperLeaderboardSort sort)
  61. {
  62. return sort == PaperLeaderboardSort.Mdd;
  63. }
  64. /// <summary>sort 문자열 파싱 — 미지정/미인식 시 기본 Return.</summary>
  65. public static PaperLeaderboardSort ParseSort(string? sort)
  66. {
  67. return sort?.Trim().ToLowerInvariant() switch
  68. {
  69. "winrate" => PaperLeaderboardSort.WinRate,
  70. "mdd" => PaperLeaderboardSort.Mdd,
  71. _ => PaperLeaderboardSort.Return
  72. };
  73. }
  74. /// <summary>period 문자열 파싱 — 미지정/미인식 시 기본 All.</summary>
  75. public static PaperLeaderboardPeriod ParsePeriod(string? period)
  76. {
  77. return period?.Trim().ToLowerInvariant() switch
  78. {
  79. "week" => PaperLeaderboardPeriod.Week,
  80. "month" => PaperLeaderboardPeriod.Month,
  81. _ => PaperLeaderboardPeriod.All
  82. };
  83. }
  84. /// <summary>sort → API 노출 문자열.</summary>
  85. public static string SortToString(PaperLeaderboardSort sort)
  86. {
  87. return sort switch
  88. {
  89. PaperLeaderboardSort.WinRate => "winrate",
  90. PaperLeaderboardSort.Mdd => "mdd",
  91. _ => "return"
  92. };
  93. }
  94. /// <summary>period → API 노출 문자열.</summary>
  95. public static string PeriodToString(PaperLeaderboardPeriod period)
  96. {
  97. return period switch
  98. {
  99. PaperLeaderboardPeriod.Week => "week",
  100. PaperLeaderboardPeriod.Month => "month",
  101. _ => "all"
  102. };
  103. }
  104. /// <summary>
  105. /// 지정 기간의 등재 자격 계좌 지표를 DB 에서 산출한다 (캐시 재시드 소스).
  106. /// return: 기간 nav 변화율. winrate: 전체 매도 기준. mdd: 최신 스냅샷.
  107. /// </summary>
  108. public static async Task<List<PaperLeaderboardMetric>> ComputeAllAsync(
  109. IAppDbContext db,
  110. PaperLeaderboardPeriod period,
  111. int minFillsForRank,
  112. CancellationToken ct)
  113. {
  114. // 각 계좌의 최신 스냅샷 (등재 자격 판정 + mdd/누적수익률/체결수)
  115. var latestByAccount = await db.PaperDailySnapshot.AsNoTracking()
  116. .GroupBy(c => c.AccountID)
  117. .Select(g => new { AccountID = g.Key, MaxDate = g.Max(c => c.TradeDate) })
  118. .ToListAsync(ct);
  119. if (latestByAccount.Count == 0)
  120. {
  121. return [];
  122. }
  123. var latestDates = latestByAccount.ToDictionary(c => c.AccountID, c => c.MaxDate);
  124. var qualifiedAccountIDs = latestByAccount.Select(c => c.AccountID).ToList();
  125. // 자격 계좌들의 스냅샷 전체 로드 (기간 시작 nav 조회 + 최신 지표) — 계좌 수 규모 소량 가정
  126. var snapshots = await db.PaperDailySnapshot.AsNoTracking()
  127. .Where(c => qualifiedAccountIDs.Contains(c.AccountID))
  128. .Select(c => new SnapshotRow(c.AccountID, c.TradeDate, c.UnitNav, c.CumReturnBp, c.MddBp, c.FillCountCum))
  129. .ToListAsync(ct);
  130. // 계좌 → 회원 매핑
  131. var accounts = await db.PaperAccount.AsNoTracking()
  132. .Where(c => qualifiedAccountIDs.Contains(c.ID))
  133. .Select(c => new { c.ID, c.MemberID })
  134. .ToListAsync(ct);
  135. var memberByAccount = accounts.ToDictionary(c => c.ID, c => c.MemberID);
  136. // 자격 계좌들의 매도 체결(승률용) — PaperFill → PaperOrder(AccountID)
  137. var sellFills = await db.PaperFill.AsNoTracking()
  138. .Where(c => c.RealizedPnL != null && qualifiedAccountIDs.Contains(c.Order.AccountID))
  139. .Select(c => new { c.Order.AccountID, c.RealizedPnL })
  140. .ToListAsync(ct);
  141. var winByAccount = sellFills
  142. .GroupBy(c => c.AccountID)
  143. .ToDictionary(g => g.Key, g => (Total: g.Count(), Win: g.Count(c => c.RealizedPnL > 0)));
  144. var byAccount = snapshots.GroupBy(c => c.AccountID);
  145. var result = new List<PaperLeaderboardMetric>();
  146. foreach (var group in byAccount)
  147. {
  148. var accountID = group.Key;
  149. if (!latestDates.TryGetValue(accountID, out var latestDate) || !memberByAccount.TryGetValue(accountID, out var memberID))
  150. {
  151. continue;
  152. }
  153. var ordered = group.OrderBy(c => c.TradeDate).ToList();
  154. var latest = ordered[^1];
  155. // 등재 자격: 최신 스냅샷 체결 수
  156. if (latest.FillCountCum < minFillsForRank)
  157. {
  158. continue;
  159. }
  160. var returnBp = ComputePeriodReturnBp(ordered, latest, latestDate, period);
  161. var winRateBp = 0;
  162. if (winByAccount.TryGetValue(accountID, out var w) && w.Total > 0)
  163. {
  164. winRateBp = (int)Math.Round(w.Win / (double)w.Total * 10000, MidpointRounding.AwayFromZero);
  165. }
  166. result.Add(new PaperLeaderboardMetric(memberID, returnBp, winRateBp, latest.MddBp, latest.FillCountCum));
  167. }
  168. return result;
  169. }
  170. /// <summary>
  171. /// 기간 수익률 Bp. all = 최신 CumReturnBp. week/month = (navLatest / navAtWindowStart - 1) × 10000,
  172. /// windowStart = latestDate - 7/30일. 시작점 스냅샷 없으면 최초 스냅샷 nav 로 폴백.
  173. /// </summary>
  174. private static int ComputePeriodReturnBp(
  175. List<SnapshotRow> ordered,
  176. SnapshotRow latest,
  177. DateOnly latestDate,
  178. PaperLeaderboardPeriod period)
  179. {
  180. if (period == PaperLeaderboardPeriod.All)
  181. {
  182. return latest.CumReturnBp;
  183. }
  184. var days = period == PaperLeaderboardPeriod.Week ? 7 : 30;
  185. var windowStart = latestDate.AddDays(-days);
  186. // windowStart 이하의 가장 최신 스냅샷 nav, 없으면 최초 스냅샷 nav 로 폴백
  187. SnapshotRow? startRow = null;
  188. foreach (var row in ordered)
  189. {
  190. if (row.TradeDate <= windowStart)
  191. {
  192. startRow = row;
  193. }
  194. }
  195. var startNav = (startRow ?? ordered[0]).UnitNav;
  196. if (startNav <= 0)
  197. {
  198. return latest.CumReturnBp;
  199. }
  200. return (int)Math.Round((latest.UnitNav / startNav - 1m) * 10000m, MidpointRounding.AwayFromZero);
  201. }
  202. private sealed record SnapshotRow(int AccountID, DateOnly TradeDate, decimal UnitNav, int CumReturnBp, int MddBp, int FillCountCum);
  203. }