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