using Application.Abstractions.Data; using Domain.Entities.Paper.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Helpers; /// /// 모의투자 리더보드 정렬 기준 (d4 §⑤). /// public enum PaperLeaderboardSort { /// 누적/기간 수익률 (높을수록 상위) Return = 0, /// 승률 (높을수록 상위) WinRate = 1, /// 최대 낙폭 MDD (낮을수록 상위) Mdd = 2 } /// /// 모의투자 리더보드 기간 (d4 §⑤). 수익률만 기간에 영향 — 승률/MDD 는 전체 기준. /// public enum PaperLeaderboardPeriod { Week = 0, Month = 1, All = 2 } /// /// 등재 자격 계좌의 리더보드 지표. /// /// 회원 PK /// 기간 수익률 Bp /// 승률 Bp (승매도/전체매도 × 10000) /// 최대 낙폭 Bp /// 누적 체결 수 public sealed record PaperLeaderboardMetric( int MemberID, int ReturnBp, int WinRateBp, int MddBp, int FillCount ); /// /// 모의투자 리더보드 지표 계산 (d4 M3 리더보드 백엔드 구현) — Redis 미의존 순수 계산 계층. /// 캐시() 재시드와 단위 테스트가 공유한다. /// 등재 자격: 최신 스냅샷의 FillCountCum >= MinFillsForRank. /// public static class PaperLeaderboardMetrics { /// 정렬 기준별 Redis Sorted Set 점수 — return/winrate 는 높을수록, mdd 는 낮을수록 상위. 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 }; } /// mdd 는 낮을수록 상위(ZRANGE 오름차순), 나머지는 높을수록 상위(ZREVRANGE 내림차순). public static bool IsAscending(PaperLeaderboardSort sort) { return sort == PaperLeaderboardSort.Mdd; } /// sort 문자열 파싱 — 미지정/미인식 시 기본 Return. public static PaperLeaderboardSort ParseSort(string? sort) { return sort?.Trim().ToLowerInvariant() switch { "winrate" => PaperLeaderboardSort.WinRate, "mdd" => PaperLeaderboardSort.Mdd, _ => PaperLeaderboardSort.Return }; } /// period 문자열 파싱 — 미지정/미인식 시 기본 All. public static PaperLeaderboardPeriod ParsePeriod(string? period) { return period?.Trim().ToLowerInvariant() switch { "week" => PaperLeaderboardPeriod.Week, "month" => PaperLeaderboardPeriod.Month, _ => PaperLeaderboardPeriod.All }; } /// sort → API 노출 문자열. public static string SortToString(PaperLeaderboardSort sort) { return sort switch { PaperLeaderboardSort.WinRate => "winrate", PaperLeaderboardSort.Mdd => "mdd", _ => "return" }; } /// period → API 노출 문자열. public static string PeriodToString(PaperLeaderboardPeriod period) { return period switch { PaperLeaderboardPeriod.Week => "week", PaperLeaderboardPeriod.Month => "month", _ => "all" }; } /// /// 지정 기간의 등재 자격 계좌 지표를 DB 에서 산출한다 (캐시 재시드 소스). /// return: 기간 nav 변화율. winrate: 전체 매도 기준. mdd: 최신 스냅샷. /// public static async Task> 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(); 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; } /// /// 기간 수익률 Bp. all = 최신 CumReturnBp. week/month = (navLatest / navAtWindowStart - 1) × 10000, /// windowStart = latestDate - 7/30일. 시작점 스냅샷 없으면 최초 스냅샷 nav 로 폴백. /// private static int ComputePeriodReturnBp( List 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); }