Эх сурвалжийг харах

feat(paper): D4 M2 — 모의투자 체결/스냅샷 배치 엔진

PaperBatchEngine (Application/Helpers):
- RunFillPassAsync — Pending 주문 T+1 시가/종가 단일가 체결. 주문 단위
  독립 컨텍스트 + 낙관적 동시성 격리 + 멱등(PaperFill OrderID UNIQUE).
  매수 예약금 정산(미사용 환급)·평균단가법, 매도 실현손익·대금입금,
  시세 부재 거부+예약 환원.
- RunSnapshotPassAsync — 상폐 강제청산(최종거래일 종가) → 전 계좌
  좌수 NAV·일/누적수익률·PeakEquity·MDD upsert (멱등).

PaperFillService (Infrastructure/StockData, HostedService):
- KST 13:10 기동 → TargetDate 시세 적재 폴링(~18:00) → 체결→스냅샷.
- BackgroundJobs:PaperFill 플래그(기본 false), Web.Api 전용 등록.

엔티티 메서드: PaperAccount.SettleBuy/AddCash, PaperPosition.ApplyBuy/
ApplySell/ForceLiquidate, PaperOrder.MarkFilled/MarkRejected, PaperFill.CreateFor.

수정: StockPrice nullable-struct 접근(price.Value) + no-price 검출
정확화(nullable 프로젝션 → "시세 없음" 정상 분기).

테스트: PaperTradingBatchTests 6종(매수/매도/거부/멱등/스냅샷 NAV·MDD/상폐청산).
빌드 0 에러, 테스트 88/88.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 долоо хоног өмнө
parent
commit
238623a4f8

+ 446 - 0
Application/Helpers/PaperBatchEngine.cs

@@ -0,0 +1,446 @@
+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;
+    }
+}

+ 36 - 0
Domain/Entities/Paper/PaperAccount.cs

@@ -133,4 +133,40 @@ public class PaperAccount
         ReservedToken -= amount;
         Token += amount;
     }
+
+    /// <summary>
+    /// 매수 체결 정산 (배치 M2) — 예약금 전액을 해제하고 실제 사용분(actualCost)을 제외한 잔여를 자유 토큰으로 환급한다.
+    /// ReservedToken -= reservedAmount; Token += (reservedAmount - actualCost). 좌수는 불변.
+    /// </summary>
+    public void SettleBuy(decimal reservedAmount, decimal actualCost)
+    {
+        if (reservedAmount <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(reservedAmount));
+        }
+
+        if (actualCost < 0 || actualCost > reservedAmount)
+        {
+            throw new ArgumentOutOfRangeException(nameof(actualCost));
+        }
+
+        if (ReservedToken < reservedAmount)
+        {
+            throw new InvalidOperationException("정산할 예약금이 부족합니다.");
+        }
+
+        ReservedToken -= reservedAmount;
+        Token += reservedAmount - actualCost;
+    }
+
+    /// <summary>매도 대금·청산 대금 입금 (배치 M2) — 자유 토큰 증가. 좌수는 불변.</summary>
+    public void AddCash(decimal amount)
+    {
+        if (amount < 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(amount));
+        }
+
+        Token += amount;
+    }
 }

+ 34 - 0
Domain/Entities/Paper/PaperFill.cs

@@ -73,4 +73,38 @@ public class PaperFill
             RealizedPnL = realizedPnL
         };
     }
+
+    /// <summary>
+    /// 신규 주문(ID 미확정) 케이스 — Order 내비게이션으로 생성해 EF 가 저장 시 FK(OrderID)를 자동 채우게 한다.
+    /// 상폐 강제청산 등 시스템 주문+체결을 한 번에 저장할 때 사용 (d4 §⑨).
+    /// </summary>
+    public static PaperFill CreateFor(
+        PaperOrder order,
+        decimal price,
+        int quantity,
+        decimal fee,
+        decimal tax,
+        decimal amount,
+        DateOnly priceDate,
+        decimal? realizedPnL = null
+    ) {
+        ArgumentNullException.ThrowIfNull(order);
+
+        if (quantity <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(quantity));
+        }
+
+        return new PaperFill
+        {
+            Order = order,
+            Price = price,
+            Quantity = quantity,
+            Fee = fee,
+            Tax = tax,
+            Amount = amount,
+            PriceDate = priceDate,
+            RealizedPnL = realizedPnL
+        };
+    }
 }

+ 53 - 0
Domain/Entities/Paper/PaperPosition.cs

@@ -96,4 +96,57 @@ public class PaperPosition
         ReservedQuantity -= quantity;
         UpdatedAt = DateTime.UtcNow;
     }
+
+    /// <summary>
+    /// 매수 체결 반영 (배치 M2) — 평균단가법으로 AvgPrice 갱신 (순수 체결가 기준, 수수료 제외).
+    /// AvgPrice = (AvgPrice × Quantity + fillPrice × qty) / (Quantity + qty).
+    /// </summary>
+    public void ApplyBuy(int qty, decimal fillPrice)
+    {
+        if (qty <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(qty));
+        }
+
+        if (fillPrice <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(fillPrice));
+        }
+
+        var newQty = Quantity + qty;
+        AvgPrice = (AvgPrice * Quantity + fillPrice * qty) / newQty;
+        Quantity = newQty;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    /// <summary>매도 체결 반영 (배치 M2) — 예약 수량과 보유 수량을 함께 차감한다.</summary>
+    public void ApplySell(int qty)
+    {
+        if (qty <= 0)
+        {
+            throw new ArgumentOutOfRangeException(nameof(qty));
+        }
+
+        if (ReservedQuantity < qty || Quantity < qty)
+        {
+            throw new InvalidOperationException("체결할 예약/보유 수량이 부족합니다.");
+        }
+
+        Quantity -= qty;
+        ReservedQuantity -= qty;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    /// <summary>
+    /// 상장폐지 강제 청산 (배치 M2) — 예약 여부와 무관하게 보유 전량을 청산한다.
+    /// 반환값은 청산된 수량. 예약 수량도 함께 0 으로 정리한다.
+    /// </summary>
+    public int ForceLiquidate()
+    {
+        var liquidated = Quantity;
+        Quantity = 0;
+        ReservedQuantity = 0;
+        UpdatedAt = DateTime.UtcNow;
+        return liquidated;
+    }
 }

+ 8 - 0
Infrastructure/DependencyInjection.cs

@@ -261,6 +261,14 @@ public static class DependencyInjection
             services.AddHostedService<DailyPriceSyncService>();
         }
 
+        // 모의투자 체결·스냅샷 배치 (d4 M2) — BackgroundJobs:PaperFill 플래그 (기본 false). Web.Api 전용.
+        var bg = configuration.GetSection("BackgroundJobs").Get<AppSettings.BackgroundJobsSection>() ?? new AppSettings.BackgroundJobsSection();
+
+        if (bg.PaperFill)
+        {
+            services.AddHostedService<PaperFillService>();
+        }
+
         return services;
     }
 

+ 108 - 0
Infrastructure/StockData/PaperFillService.cs

@@ -0,0 +1,108 @@
+using Application.Abstractions.Data;
+using Application.Helpers;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.StockData;
+
+/// <summary>
+/// 모의투자 체결·스냅샷 배치 (d4 M2). KST 13:10 기동 후 직전 영업일(TargetDate) 시세 적재를 폴링하고
+/// (StockDailyPrice 존재 여부, 10분 간격 ~18:00), 적재되면 체결 패스 → 스냅샷 패스를 1회 수행한다.
+///
+/// DailyPriceSyncService(13:10) 가 시세를 먼저 넣으므로 그 뒤에 자연히 통과한다. 시세 미적재 상태로
+/// 마감 시각을 넘기면 그날은 skip 하고 다음 날 다시 시도한다. BackgroundJobs:PaperFill 플래그로 토글,
+/// Web.Api 전용(중복 실행 방지). 체결은 주문 단위 컨텍스트로 격리(PaperBatchEngine 참조).
+/// </summary>
+internal sealed class PaperFillService(
+    IServiceScopeFactory scopeFactory,
+    IAppDbContextFactory dbContextFactory,
+    ILogger<PaperFillService> logger
+) : DailyScheduledService(logger)
+{
+    /// <summary>폴링 시작 시각(KST) — DailyPriceSync 13:10 직후.</summary>
+    private static readonly TimeOnly StartTime = new(13, 10);
+
+    /// <summary>폴링 마감 시각(KST) — 이때까지 시세 미적재면 그날 skip.</summary>
+    private static readonly TimeOnly Deadline = new(18, 0);
+
+    /// <summary>시세 적재 폴링 간격.</summary>
+    private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
+
+    protected override string JobName => "PaperFill";
+
+    protected override TimeOnly TargetTime => StartTime;
+
+    protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
+    {
+        // 직전 영업일 = 이번에 적재될 시세의 TargetDate (DailyPriceSyncService 와 동일 기준)
+        DateOnly targetDate;
+        using (var scope = scopeFactory.CreateScope())
+        {
+            var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+            targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst, ct);
+        }
+
+        // 시세 적재 폴링 (10분 간격, 마감 시각까지)
+        var loaded = await WaitForPricesAsync(targetDate, ct);
+        if (!loaded)
+        {
+            Logger.LogWarning("[{Job}] targetDate={TargetDate} 시세 미적재 — 마감({Deadline}) 초과, 오늘 skip", JobName, targetDate, Deadline);
+            return true; // 다음 날 다시 시도
+        }
+
+        // 1) 체결 패스 — 주문 단위 독립 컨텍스트 (동시성 격리 + 멱등)
+        var fillResult = await PaperBatchEngine.RunFillPassAsync(dbContextFactory.CreateDbContext, targetDate, ct);
+        Logger.LogInformation("[{Job}] 체결 완료 — targetDate={TargetDate}, considered={Considered}, filled={Filled}, rejected={Rejected}, skipped={Skipped}, conflicts={Conflicts}",
+            JobName, targetDate, fillResult.Considered, fillResult.Filled, fillResult.Rejected, fillResult.Skipped, fillResult.Conflicts);
+
+        // 2) 스냅샷 패스 — 상폐 청산 + 좌수 NAV/수익률/MDD upsert (단일 컨텍스트)
+        using (var scope = scopeFactory.CreateScope())
+        {
+            var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+            var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
+            var feeRateBp = paper?.FeeRateBp ?? 15;
+            var taxRateBp = paper?.TaxRateBp ?? 18;
+
+            var snapResult = await PaperBatchEngine.RunSnapshotPassAsync(db, targetDate, feeRateBp, taxRateBp, ct);
+            Logger.LogInformation("[{Job}] 스냅샷 완료 — targetDate={TargetDate}, liquidated={Liquidated}, snapshots={Snapshots}",
+                JobName, targetDate, snapResult.Liquidated, snapResult.Snapshots);
+        }
+
+        return true;
+    }
+
+    /// <summary>targetDate 의 StockDailyPrice 가 적재될 때까지 마감 시각(KST)까지 10분 간격 폴링.</summary>
+    private async Task<bool> WaitForPricesAsync(DateOnly targetDate, CancellationToken ct)
+    {
+        while (!ct.IsCancellationRequested)
+        {
+            using (var scope = scopeFactory.CreateScope())
+            {
+                var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+                if (await db.StockDailyPrice.AsNoTracking().AnyAsync(c => c.TradingDate == targetDate, ct))
+                {
+                    return true;
+                }
+            }
+
+            if (TimeOnly.FromDateTime(NowKst()) >= Deadline)
+            {
+                return false;
+            }
+
+            Logger.LogInformation("[{Job}] targetDate={TargetDate} 시세 미적재 — {Interval} 후 재확인", JobName, targetDate, PollInterval);
+
+            try
+            {
+                await Task.Delay(PollInterval, ct);
+            }
+            catch (OperationCanceledException)
+            {
+                return false;
+            }
+        }
+
+        return false;
+    }
+}

+ 3 - 0
SharedKernel/AppSetting.cs

@@ -94,6 +94,9 @@ public sealed class AppSettings
 
         /// <summary>결제 자동 대사 배치 (Toss NeedsReconciliation 재확인 — PaymentReconcileService). Features:Channel 게이트와 무관.</summary>
         public bool PaymentReconcile { get; init; } = true;
+
+        /// <summary>모의투자 체결·스냅샷 배치 (PaperFillService — d4 M2). Features:Channel 게이트와 무관, 기본 false (StockData 적재 가동 후 활성화).</summary>
+        public bool PaperFill { get; init; } = false;
     }
 
     /// <summary>

+ 276 - 0
Tests/Application.Tests/PaperTradingBatchTests.cs

@@ -0,0 +1,276 @@
+using Application.Abstractions.Data;
+using Application.Helpers;
+using Domain.Entities.Paper;
+using Domain.Entities.Paper.ValueObject;
+using Domain.Entities.Stocks;
+using Domain.Entities.Stocks.ValueObject;
+using Infrastructure.Persistence;
+
+namespace Application.Tests;
+
+/// <summary>
+/// 모의투자 체결·스냅샷 배치 엔진(PaperBatchEngine) 검증 — 매수/매도 체결, 수수료·세금·실현손익,
+/// 시세 부재 거부+예약 환원, 재실행 멱등, 좌수 NAV·누적수익률·MDD, 상폐 강제청산 (d4 M2).
+/// LocalDB(antooza_test) 공유 — 각 테스트는 고유 종목코드(95000x)/회원으로 격리한다.
+/// </summary>
+[TestClass]
+public sealed class PaperTradingBatchTests
+{
+    private static Func<IAppDbContext> Factory => () => TestDb.Create();
+
+    private static async Task<Stock> CreateStockAsync(AppDbContext db, string code, string name = "테스트종목", bool delisted = false)
+    {
+        var stock = Stock.Create(code, name, StockMarket.KOSPI, new DateOnly(2020, 1, 2));
+        if (delisted)
+        {
+            stock.MarkDelisted(new DateOnly(2026, 1, 2));
+        }
+
+        await db.Stock.AddAsync(stock);
+        await db.SaveChangesAsync(default);
+        return stock;
+    }
+
+    // Create(stockID, tradingDate, open, high, low, close, volume, amount, priceChange, changeRate)
+    private static async Task AddDailyPriceAsync(AppDbContext db, int stockID, DateOnly date, int open, int close)
+    {
+        await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stockID, date, open, Math.Max(open, close), Math.Min(open, close), close, 1_000_000L, 1_000_000_000L, 0, 0m));
+        await db.SaveChangesAsync(default);
+    }
+
+    [TestMethod]
+    public async Task BuyFill_AtOpen_SettlesReserveAndCreatesPosition()
+    {
+        // 기대: 매수 시가 체결 → 예약금 정산(미사용분 환급) + 포지션 생성(평균단가) + 수수료 기록.
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(1_000_000m, 1_000_000m);
+        account.ReserveForBuy(135_150m); // ceil(10000*10*1.35)=135000 + fee 150
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        var stock = await CreateStockAsync(db, "950001");
+        var targetDate = new DateOnly(2026, 6, 10);
+        await AddDailyPriceAsync(db, stock.ID, targetDate, open: 10000, close: 11000);
+
+        var order = PaperOrder.Create(account.ID, "950001", PaperOrderSide.Buy, PaperFillRule.Open, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
+        await db.PaperOrder.AddAsync(order);
+        await db.SaveChangesAsync(default);
+
+        await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
+
+        using var verify = TestDb.Create();
+        var o = await verify.PaperOrder.FindAsync(order.ID);
+        Assert.AreEqual(PaperOrderStatus.Filled, o!.Status);
+
+        var acc = await verify.PaperAccount.FindAsync(account.ID);
+        Assert.AreEqual(899_850m, acc!.Token, "1,000,000 - (gross 100,000 + fee 150) = 899,850");
+        Assert.AreEqual(0m, acc.ReservedToken);
+        Assert.AreEqual(1_000_000m, acc.Units, "매매는 좌수 불변");
+
+        var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950001");
+        Assert.AreEqual(10, pos.Quantity);
+        Assert.AreEqual(10000m, pos.AvgPrice);
+
+        var fill = verify.PaperFill.Single(c => c.OrderID == order.ID);
+        Assert.AreEqual(10000m, fill.Price);
+        Assert.AreEqual(150m, fill.Fee);
+        Assert.AreEqual(100_000m, fill.Amount);
+        Assert.IsNull(fill.RealizedPnL);
+    }
+
+    [TestMethod]
+    public async Task SellFill_RealizesPnLAndCreditsProceeds()
+    {
+        // 기대: 매도 종가 체결 → 실현손익 = (체결가-평단)*수량 - 수수료 - 세금, 대금 입금, 포지션 차감.
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(100_000m, 100_000m);
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        var stock = await CreateStockAsync(db, "950002");
+        var position = PaperPosition.Create(account.ID, "950002", 10, 10000m);
+        position.ReserveSell(10);
+        await db.PaperPosition.AddAsync(position);
+        await db.SaveChangesAsync(default);
+
+        var targetDate = new DateOnly(2026, 6, 13);
+        await AddDailyPriceAsync(db, stock.ID, targetDate, open: 11000, close: 12000);
+
+        var order = PaperOrder.Create(account.ID, "950002", PaperOrderSide.Sell, PaperFillRule.Close, 10, 0m, targetDate, DateTime.UtcNow.AddDays(1));
+        await db.PaperOrder.AddAsync(order);
+        await db.SaveChangesAsync(default);
+
+        await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
+
+        using var verify = TestDb.Create();
+        var o = await verify.PaperOrder.FindAsync(order.ID);
+        Assert.AreEqual(PaperOrderStatus.Filled, o!.Status);
+
+        var acc = await verify.PaperAccount.FindAsync(account.ID);
+        // gross 120,000; fee 180; tax 216; proceeds 119,604; token 100,000 + 119,604
+        Assert.AreEqual(219_604m, acc!.Token);
+
+        var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950002");
+        Assert.AreEqual(0, pos.Quantity);
+        Assert.AreEqual(0, pos.ReservedQuantity);
+
+        var fill = verify.PaperFill.Single(c => c.OrderID == order.ID);
+        Assert.AreEqual(12000m, fill.Price);
+        Assert.AreEqual(180m, fill.Fee);
+        Assert.AreEqual(216m, fill.Tax);
+        Assert.AreEqual(19_604m, fill.RealizedPnL, "(12000-10000)*10 - 180 - 216 = 19,604");
+    }
+
+    [TestMethod]
+    public async Task BuyFill_NoTargetPrice_RejectsAndReleasesReserve()
+    {
+        // 기대: TargetDate 시세가 없으면 거부("시세 없음") + 예약금 전액 환원.
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(1_000_000m, 1_000_000m);
+        account.ReserveForBuy(135_150m);
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        await CreateStockAsync(db, "950003"); // 시세 미적재
+        var targetDate = new DateOnly(2026, 6, 12);
+
+        var order = PaperOrder.Create(account.ID, "950003", PaperOrderSide.Buy, PaperFillRule.Close, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
+        await db.PaperOrder.AddAsync(order);
+        await db.SaveChangesAsync(default);
+
+        await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
+
+        using var verify = TestDb.Create();
+        var o = await verify.PaperOrder.FindAsync(order.ID);
+        Assert.AreEqual(PaperOrderStatus.Rejected, o!.Status);
+        Assert.AreEqual("시세 없음", o.RejectReason);
+
+        var acc = await verify.PaperAccount.FindAsync(account.ID);
+        Assert.AreEqual(1_000_000m, acc!.Token);
+        Assert.AreEqual(0m, acc.ReservedToken);
+        Assert.IsFalse(verify.PaperFill.Any(c => c.OrderID == order.ID));
+    }
+
+    [TestMethod]
+    public async Task Fill_ReRun_IsIdempotent()
+    {
+        // 기대: 동일 loadedDate 로 두 번 실행해도 이중 체결되지 않는다 (PaperFill OrderID UNIQUE).
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(1_000_000m, 1_000_000m);
+        account.ReserveForBuy(135_150m);
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        var stock = await CreateStockAsync(db, "950004");
+        var targetDate = new DateOnly(2026, 6, 11);
+        await AddDailyPriceAsync(db, stock.ID, targetDate, open: 10000, close: 10000);
+
+        var order = PaperOrder.Create(account.ID, "950004", PaperOrderSide.Buy, PaperFillRule.Open, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
+        await db.PaperOrder.AddAsync(order);
+        await db.SaveChangesAsync(default);
+
+        await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
+        await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
+
+        using var verify = TestDb.Create();
+        Assert.AreEqual(1, verify.PaperFill.Count(c => c.OrderID == order.ID), "재실행해도 체결은 1건");
+        var acc = await verify.PaperAccount.FindAsync(account.ID);
+        Assert.AreEqual(899_850m, acc!.Token);
+        var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950004");
+        Assert.AreEqual(10, pos.Quantity, "수량이 20 으로 이중 반영되지 않는다");
+    }
+
+    [TestMethod]
+    public async Task Snapshot_NavCumReturnAndMdd_AcrossTwoDates()
+    {
+        // 기대: 좌수 불변 상태에서 포지션 평가 하락이 nav/누적수익률/MDD 에 반영된다.
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(1_000_000m, 1_000_000m);
+        account.ReserveForBuy(500_000m);
+        account.SettleBuy(500_000m, 500_000m); // 현금 500,000, 포지션 취득 모사
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        var stock = await CreateStockAsync(db, "950005");
+        var position = PaperPosition.Create(account.ID, "950005", 100, 5000m);
+        await db.PaperPosition.AddAsync(position);
+        await db.SaveChangesAsync(default);
+
+        var date1 = new DateOnly(2026, 6, 1);
+        await AddDailyPriceAsync(db, stock.ID, date1, open: 5000, close: 5000);
+        using (var snap1 = TestDb.Create())
+        {
+            await PaperBatchEngine.RunSnapshotPassAsync(snap1, date1, 15, 18, default);
+        }
+
+        var date2 = new DateOnly(2026, 6, 2);
+        await AddDailyPriceAsync(db, stock.ID, date2, open: 4000, close: 4000);
+        using (var snap2 = TestDb.Create())
+        {
+            await PaperBatchEngine.RunSnapshotPassAsync(snap2, date2, 15, 18, default);
+        }
+
+        using var verify = TestDb.Create();
+        var s1 = verify.PaperDailySnapshot.Single(c => c.AccountID == account.ID && c.TradeDate == date1);
+        Assert.AreEqual(1m, s1.UnitNav);
+        Assert.AreEqual(0, s1.CumReturnBp);
+        Assert.AreEqual(1_000_000m, s1.PeakEquity);
+        Assert.AreEqual(0, s1.MddBp);
+
+        var s2 = verify.PaperDailySnapshot.Single(c => c.AccountID == account.ID && c.TradeDate == date2);
+        Assert.AreEqual(0.9m, s2.UnitNav, "equity 900,000 / units 1,000,000 = 0.9");
+        Assert.AreEqual(-1000, s2.CumReturnBp);
+        Assert.AreEqual(-1000, s2.DailyReturnBp);
+        Assert.AreEqual(1_000_000m, s2.PeakEquity);
+        Assert.AreEqual(1000, s2.MddBp, "(1,000,000 - 900,000)/1,000,000 = 10% = 1000bp");
+    }
+
+    [TestMethod]
+    public async Task Snapshot_LiquidatesDelistedPositionAtLastClose()
+    {
+        // 기대: 상폐(IsActive=false) 종목 보유분을 최종 거래일 종가로 강제청산 후 대금 입금 (d4 §⑨).
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var account = PaperAccount.Create(memberID);
+        account.Deposit(1_000_000m, 1_000_000m);
+        account.ReserveForBuy(500_000m);
+        account.SettleBuy(500_000m, 500_000m); // 현금 500,000
+        await db.PaperAccount.AddAsync(account);
+        await db.SaveChangesAsync(default);
+
+        var stock = await CreateStockAsync(db, "950006");
+        var position = PaperPosition.Create(account.ID, "950006", 100, 5000m);
+        await db.PaperPosition.AddAsync(position);
+        await db.SaveChangesAsync(default);
+
+        var lastDate = new DateOnly(2026, 6, 20);
+        await AddDailyPriceAsync(db, stock.ID, lastDate, open: 6000, close: 6000);
+        var s = await db.Stock.FindAsync(stock.ID);
+        s!.MarkDelisted(lastDate);
+        await db.SaveChangesAsync(default);
+
+        var tradeDate = new DateOnly(2026, 6, 21);
+        using (var snap = TestDb.Create())
+        {
+            await PaperBatchEngine.RunSnapshotPassAsync(snap, tradeDate, 15, 18, default);
+        }
+
+        using var verify = TestDb.Create();
+        var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950006");
+        Assert.AreEqual(0, pos.Quantity, "상폐 포지션은 강제청산되어 0");
+
+        var acc = await verify.PaperAccount.FindAsync(account.ID);
+        // gross 600,000; fee 900; tax 1,080; proceeds 598,020; token 500,000 + 598,020
+        Assert.AreEqual(1_098_020m, acc!.Token);
+    }
+}