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