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