using Application.Abstractions.Data; using Domain.Entities.Paper; using Domain.Entities.Paper.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Helpers; /// /// 모의투자 체결·스냅샷 배치 엔진 (d4 M2 체결/스냅샷 배치 구현). /// D1 이 TargetDate 일별 시세를 적재하면 Pending 주문을 시가/종가로 기표(Fill)하고, /// 그 직후 전 계좌 좌수 NAV·수익률·MDD 를 산출해 PaperDailySnapshot 을 upsert 한다. /// /// 스케줄/폴링은 Infrastructure 의 HostedService(PaperFillService)가 담당하고, 여기서는 /// 순수 배치 로직만 다뤄 IAppDbContext 로 단위 테스트가 가능하도록 분리했다. /// /// 체결 규칙(d4 §③): /// • 체결가 = FillRule==Open ? Open : Close (당일 OHLCV 단일가 전량 체결, 부분체결 없음) /// • 매수: fee = floor(gross×FeeRateBp/10000), cost = gross + fee. cost > 예약금이면 거부 + 전액 환원. /// 정산 = SettleBuy(예약금, cost) → 예약 해제 + 미사용분 환급. 평균단가법으로 포지션 갱신. /// • 매도: fee = floor(gross×FeeRateBp/10000), tax = floor(gross×TaxRateBp/10000), proceeds = gross - fee - tax. /// realizedPnL = (체결가 - AvgPrice)×수량 - fee - tax. 예약/보유 수량 차감 + 대금 입금. /// • 시세 부재(휴장·상폐·미적재) → Rejected("시세 없음") + 예약 환원. /// • 좌수 불변 — 매매는 Equity 만 변동, nav 상승률 = 순수 운용 수익률. /// • 재실행 멱등 — PaperFill(OrderID UNIQUE) 존재 시 skip, Pending 아니면 skip. /// public static class PaperBatchEngine { /// 체결 결과 요약 (배치 로그/테스트용). public readonly record struct FillPassResult(int Considered, int Filled, int Rejected, int Skipped, int Conflicts); /// 스냅샷 결과 요약 (배치 로그/테스트용). public readonly record struct SnapshotPassResult(int Liquidated, int Snapshots); /// /// 체결 패스 — loadedDate 이하 TargetDate 의 Pending 주문을 오래된 순으로 기표한다. /// 각 주문은 ctxFactory 로 만든 독립 컨텍스트에서 처리 → 주문 단위 트랜잭션 + 낙관적 동시성 격리. /// 한 주문의 DbUpdateConcurrencyException 은 건너뛰고(다음 실행 재시도) 패스 전체를 멈추지 않는다. /// public static async Task RunFillPassAsync( Func ctxFactory, DateOnly loadedDate, CancellationToken ct) { int feeRateBp; int taxRateBp; List orderIDs; // 대상 주문 목록 + Config 는 읽기 전용 컨텍스트에서 한 번만 로드 var readCtx = ctxFactory(); await using (readCtx.ConfigureAwait(false)) { var paper = await readCtx.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct); feeRateBp = paper?.FeeRateBp ?? 15; taxRateBp = paper?.TaxRateBp ?? 18; orderIDs = await readCtx.PaperOrder.AsNoTracking() .Where(c => c.Status == PaperOrderStatus.Pending && c.TargetDate <= loadedDate) .OrderBy(c => c.CreatedAt).ThenBy(c => c.ID) .Select(c => c.ID) .ToListAsync(ct); } var filled = 0; var rejected = 0; var skipped = 0; var conflicts = 0; foreach (var orderID in orderIDs) { if (ct.IsCancellationRequested) { break; } var outcome = await FillOneAsync(ctxFactory, orderID, feeRateBp, taxRateBp, ct); switch (outcome) { case FillOutcome.Filled: filled++; break; case FillOutcome.Rejected: rejected++; break; case FillOutcome.Conflict: conflicts++; break; default: skipped++; break; } } return new FillPassResult(orderIDs.Count, filled, rejected, skipped, conflicts); } private enum FillOutcome { Skipped, Filled, Rejected, Conflict } private static async Task FillOneAsync( Func ctxFactory, int orderID, int feeRateBp, int taxRateBp, CancellationToken ct) { var db = ctxFactory(); await using (db.ConfigureAwait(false)) { var order = await db.PaperOrder.FirstOrDefaultAsync(c => c.ID == orderID, ct); if (order is null || order.Status != PaperOrderStatus.Pending) { return FillOutcome.Skipped; } // 멱등 — 이미 체결(PaperFill) 존재 시 skip if (await db.PaperFill.AnyAsync(c => c.OrderID == orderID, ct)) { return FillOutcome.Skipped; } var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.ID == order.AccountID, ct); if (account is null) { return FillOutcome.Skipped; } // TargetDate 시세 조회 (Stock.Code → Stock.ID → StockDailyPrice) var stockID = await db.Stock.AsNoTracking().Where(c => c.Code == order.StockCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct); StockPrice? price = null; if (stockID is not null) { price = await db.StockDailyPrice.AsNoTracking() .Where(c => c.StockID == stockID.Value && c.TradingDate == order.TargetDate) .Select(c => (StockPrice?)new StockPrice(c.Open, c.Close)) .FirstOrDefaultAsync(ct); } // 시세 부재 → 거부 + 예약 환원 if (price is null) { RejectAndRelease(order, account, await LoadPositionAsync(db, order, ct), "시세 없음"); return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict; } var fillPrice = order.FillRule == PaperFillRule.Open ? price.Value.Open : price.Value.Close; if (fillPrice <= 0) { RejectAndRelease(order, account, await LoadPositionAsync(db, order, ct), "체결가 오류"); return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict; } var gross = fillPrice * order.Quantity; var fee = Math.Floor(gross * feeRateBp / 10000m); if (order.Side == PaperOrderSide.Buy) { var cost = gross + fee; // 예약금(±35% 버퍼) 초과 → 거부 + 예약 전액 환원 if (cost > order.ReservedAmount) { order.MarkRejected("예약금 초과"); if (order.ReservedAmount > 0) { account.ReleaseBuyReserve(order.ReservedAmount); } return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict; } account.SettleBuy(order.ReservedAmount, cost); var position = await LoadPositionAsync(db, order, ct); if (position is null) { position = PaperPosition.Create(account.ID, order.StockCode, 0, 0m); db.PaperPosition.Add(position); } position.ApplyBuy(order.Quantity, fillPrice); db.PaperFill.Add(PaperFill.Create(order.ID, fillPrice, order.Quantity, fee, 0m, gross, order.TargetDate)); order.MarkFilled(); } else { var tax = Math.Floor(gross * taxRateBp / 10000m); var proceeds = gross - fee - tax; var position = await LoadPositionAsync(db, order, ct); if (position is null || position.Quantity < order.Quantity || position.ReservedQuantity < order.Quantity) { // 매도 체결 시점에 포지션이 어긋나면(비정상) 거부 + 예약 환원 시도 RejectAndRelease(order, account, position, "보유 수량 부족"); return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict; } var realizedPnL = (fillPrice - position.AvgPrice) * order.Quantity - fee - tax; position.ApplySell(order.Quantity); account.AddCash(proceeds); db.PaperFill.Add(PaperFill.Create(order.ID, fillPrice, order.Quantity, fee, tax, gross, order.TargetDate, realizedPnL)); order.MarkFilled(); } return await CommitAsync(db, ct) ? FillOutcome.Filled : FillOutcome.Conflict; } } /// 거부 처리 + 사이드별 예약 환원 (매수: 예약금, 매도: 예약 수량). private static void RejectAndRelease(PaperOrder order, PaperAccount account, PaperPosition? position, string reason) { order.MarkRejected(reason); if (order.Side == PaperOrderSide.Buy) { if (order.ReservedAmount > 0) { account.ReleaseBuyReserve(order.ReservedAmount); } } else if (position is not null && position.ReservedQuantity >= order.Quantity) { position.ReleaseSellReserve(order.Quantity); } } private static async Task LoadPositionAsync(IAppDbContext db, PaperOrder order, CancellationToken ct) { return await db.PaperPosition.FirstOrDefaultAsync(c => c.AccountID == order.AccountID && c.StockCode == order.StockCode, ct); } /// SaveChanges — 낙관적 동시성 충돌은 false 로 신호(다음 실행 재시도). private static async Task CommitAsync(IAppDbContext db, CancellationToken ct) { try { await db.SaveChangesAsync(ct); return true; } catch (DbUpdateConcurrencyException) { return false; } } private readonly record struct StockPrice(int Open, int Close); /// /// 스냅샷 패스 — tradeDate 기준 (1) 상폐 포지션 강제청산 (2) 전 계좌 좌수 NAV·수익률·MDD upsert. /// 단일 컨텍스트에서 수행하며, 상폐 청산은 시스템 주문/체결로 정상 회계 흐름을 태운다. /// 재실행 시 동일 (AccountID, TradeDate) 스냅샷을 덮어쓰므로 멱등하다. /// public static async Task RunSnapshotPassAsync( IAppDbContext db, DateOnly tradeDate, int feeRateBp, int taxRateBp, CancellationToken ct) { var liquidated = await LiquidateDelistedAsync(db, tradeDate, feeRateBp, taxRateBp, ct); // 활동 계좌 = 좌수 보유 or 포지션 보유 계좌 var accounts = await db.PaperAccount.ToListAsync(ct); var snapshots = 0; foreach (var account in accounts) { if (ct.IsCancellationRequested) { break; } var positions = await db.PaperPosition.AsNoTracking() .Where(c => c.AccountID == account.ID && c.Quantity > 0) .Select(c => new { c.StockCode, c.Quantity }) .ToListAsync(ct); // 활동이 없고 좌수도 없으면 스냅샷 skip if (account.Units <= 0 && positions.Count == 0) { continue; } var closes = await GetClosesAtAsync(db, positions.Select(c => c.StockCode).Distinct().ToList(), tradeDate, ct); var positionsValue = 0m; foreach (var p in positions) { if (closes.TryGetValue(p.StockCode, out var close)) { positionsValue += close * p.Quantity; } } var token = account.Token + account.ReservedToken; var equity = token + positionsValue; var nav = account.Units > 0 ? equity / account.Units : 1m; var prior = await db.PaperDailySnapshot.AsNoTracking() .Where(c => c.AccountID == account.ID && c.TradeDate < tradeDate) .OrderByDescending(c => c.TradeDate) .Select(c => new { c.UnitNav, c.PeakEquity, c.MddBp }) .FirstOrDefaultAsync(ct); var priorNav = prior?.UnitNav ?? 1m; var priorPeak = prior?.PeakEquity ?? 0m; var priorMdd = prior?.MddBp ?? 0; var dailyReturnBp = priorNav > 0 ? (int)Math.Round((nav / priorNav - 1m) * 10000m) : 0; var cumReturnBp = (int)Math.Round((nav - 1m) * 10000m); var peakEquity = Math.Max(priorPeak, equity); var drawdownBp = peakEquity > 0 ? (int)Math.Round((peakEquity - equity) / peakEquity * 10000m) : 0; var mddBp = Math.Max(priorMdd, drawdownBp); var fillCountCum = await db.PaperFill.AsNoTracking() .CountAsync(c => c.Order!.AccountID == account.ID && c.PriceDate <= tradeDate, ct); var existing = await db.PaperDailySnapshot.FirstOrDefaultAsync(c => c.AccountID == account.ID && c.TradeDate == tradeDate, ct); if (existing is null) { db.PaperDailySnapshot.Add(PaperDailySnapshot.Create(account.ID, tradeDate, token, positionsValue, equity, nav, dailyReturnBp, cumReturnBp, peakEquity, mddBp, fillCountCum)); } else { existing.Update(token, positionsValue, equity, nav, dailyReturnBp, cumReturnBp, peakEquity, mddBp, fillCountCum); } snapshots++; } await db.SaveChangesAsync(ct); return new SnapshotPassResult(liquidated, snapshots); } /// /// 상폐(IsActive=false) 종목 보유 포지션을 최종 거래일 종가로 강제 청산한다 (d4 §⑨). /// 시스템 매도 주문(Filled) + 체결(PaperFill)을 생성해 정상 회계 흐름을 태운다 — 멱등: /// 이미 청산된(Quantity=0) 포지션은 대상이 아니다. /// private static async Task LiquidateDelistedAsync( IAppDbContext db, DateOnly tradeDate, int feeRateBp, int taxRateBp, CancellationToken ct) { var delistedCodes = await db.Stock.AsNoTracking().Where(c => !c.IsActive).Select(c => c.Code).ToListAsync(ct); if (delistedCodes.Count == 0) { return 0; } var positions = await db.PaperPosition .Where(c => c.Quantity > 0 && delistedCodes.Contains(c.StockCode)) .ToListAsync(ct); if (positions.Count == 0) { return 0; } var count = 0; foreach (var position in positions) { var stockID = await db.Stock.AsNoTracking().Where(c => c.Code == position.StockCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct); if (stockID is null) { continue; } var last = await db.StockDailyPrice.AsNoTracking() .Where(c => c.StockID == stockID.Value) .OrderByDescending(c => c.TradingDate) .Select(c => new { c.Close, c.TradingDate }) .FirstOrDefaultAsync(ct); var lastClose = last?.Close ?? 0; var lastDate = last?.TradingDate ?? tradeDate; var qty = position.Quantity; var gross = (decimal)lastClose * qty; var fee = Math.Floor(gross * feeRateBp / 10000m); var tax = Math.Floor(gross * taxRateBp / 10000m); var proceeds = gross - fee - tax; if (proceeds < 0) { proceeds = 0; } var realizedPnL = (lastClose - position.AvgPrice) * qty - fee - tax; var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.ID == position.AccountID, ct); if (account is null) { continue; } // 시스템 매도 주문(예약 없음, 즉시 Filled) → 정상 회계 흐름 var order = PaperOrder.Create(account.ID, position.StockCode, PaperOrderSide.Sell, PaperFillRule.Close, qty, 0m, lastDate, DateTime.UtcNow); order.MarkFilled(); db.PaperOrder.Add(order); position.ForceLiquidate(); account.AddCash(proceeds); db.PaperFill.Add(PaperFill.CreateFor(order, lastClose, qty, fee, tax, gross, lastDate, realizedPnL)); count++; } await db.SaveChangesAsync(ct); return count; } /// 지정 거래일(tradeDate) 종가 맵 — 해당일 시세가 없으면 가장 최근 종가로 fallback. private static async Task> GetClosesAtAsync( IAppDbContext db, IReadOnlyCollection stockCodes, DateOnly tradeDate, CancellationToken ct) { var result = new Dictionary(); if (stockCodes.Count == 0) { return result; } var idByCode = await db.Stock.AsNoTracking().Where(c => stockCodes.Contains(c.Code)).Select(c => new { c.Code, c.ID }).ToListAsync(ct); foreach (var s in idByCode) { var atDate = await db.StockDailyPrice.AsNoTracking() .Where(c => c.StockID == s.ID && c.TradingDate <= tradeDate) .OrderByDescending(c => c.TradingDate) .Select(c => (int?)c.Close) .FirstOrDefaultAsync(ct); if (atDate.HasValue) { result[s.Code] = atDate.Value; } } return result; } }