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