|
@@ -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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|