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; /// /// 모의투자 체결·스냅샷 배치 엔진(PaperBatchEngine) 검증 — 매수/매도 체결, 수수료·세금·실현손익, /// 시세 부재 거부+예약 환원, 재실행 멱등, 좌수 NAV·누적수익률·MDD, 상폐 강제청산 (d4 M2). /// LocalDB(antooza_test) 공유 — 각 테스트는 고유 종목코드(95000x)/회원으로 격리한다. /// [TestClass] public sealed class PaperTradingBatchTests { private static Func Factory => () => TestDb.Create(); private static async Task 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); } }