| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446 |
- using Application.Abstractions.Data;
- using Domain.Entities.Paper;
- using Domain.Entities.Paper.ValueObject;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Helpers;
- /// <summary>
- /// 모의투자 체결·스냅샷 배치 엔진 (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.
- /// </summary>
- public static class PaperBatchEngine
- {
- /// <summary>체결 결과 요약 (배치 로그/테스트용).</summary>
- public readonly record struct FillPassResult(int Considered, int Filled, int Rejected, int Skipped, int Conflicts);
- /// <summary>스냅샷 결과 요약 (배치 로그/테스트용).</summary>
- public readonly record struct SnapshotPassResult(int Liquidated, int Snapshots);
- /// <summary>
- /// 체결 패스 — loadedDate 이하 TargetDate 의 Pending 주문을 오래된 순으로 기표한다.
- /// 각 주문은 ctxFactory 로 만든 독립 컨텍스트에서 처리 → 주문 단위 트랜잭션 + 낙관적 동시성 격리.
- /// 한 주문의 DbUpdateConcurrencyException 은 건너뛰고(다음 실행 재시도) 패스 전체를 멈추지 않는다.
- /// </summary>
- public static async Task<FillPassResult> RunFillPassAsync(
- Func<IAppDbContext> ctxFactory,
- DateOnly loadedDate,
- CancellationToken ct)
- {
- int feeRateBp;
- int taxRateBp;
- List<int> 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<FillOutcome> FillOneAsync(
- Func<IAppDbContext> 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;
- }
- }
- /// <summary>거부 처리 + 사이드별 예약 환원 (매수: 예약금, 매도: 예약 수량).</summary>
- 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<PaperPosition?> LoadPositionAsync(IAppDbContext db, PaperOrder order, CancellationToken ct)
- {
- return await db.PaperPosition.FirstOrDefaultAsync(c => c.AccountID == order.AccountID && c.StockCode == order.StockCode, ct);
- }
- /// <summary>SaveChanges — 낙관적 동시성 충돌은 false 로 신호(다음 실행 재시도).</summary>
- private static async Task<bool> 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);
- /// <summary>
- /// 스냅샷 패스 — tradeDate 기준 (1) 상폐 포지션 강제청산 (2) 전 계좌 좌수 NAV·수익률·MDD upsert.
- /// 단일 컨텍스트에서 수행하며, 상폐 청산은 시스템 주문/체결로 정상 회계 흐름을 태운다.
- /// 재실행 시 동일 (AccountID, TradeDate) 스냅샷을 덮어쓰므로 멱등하다.
- /// </summary>
- public static async Task<SnapshotPassResult> 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);
- }
- /// <summary>
- /// 상폐(IsActive=false) 종목 보유 포지션을 최종 거래일 종가로 강제 청산한다 (d4 §⑨).
- /// 시스템 매도 주문(Filled) + 체결(PaperFill)을 생성해 정상 회계 흐름을 태운다 — 멱등:
- /// 이미 청산된(Quantity=0) 포지션은 대상이 아니다.
- /// </summary>
- private static async Task<int> 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;
- }
- /// <summary>지정 거래일(tradeDate) 종가 맵 — 해당일 시세가 없으면 가장 최근 종가로 fallback.</summary>
- private static async Task<Dictionary<string, decimal>> GetClosesAtAsync(
- IAppDbContext db,
- IReadOnlyCollection<string> stockCodes,
- DateOnly tradeDate,
- CancellationToken ct)
- {
- var result = new Dictionary<string, decimal>();
- 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;
- }
- }
|