PaperTradingBatchTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. using Application.Abstractions.Data;
  2. using Application.Helpers;
  3. using Domain.Entities.Paper;
  4. using Domain.Entities.Paper.ValueObject;
  5. using Domain.Entities.Stocks;
  6. using Domain.Entities.Stocks.ValueObject;
  7. using Infrastructure.Persistence;
  8. namespace Application.Tests;
  9. /// <summary>
  10. /// 모의투자 체결·스냅샷 배치 엔진(PaperBatchEngine) 검증 — 매수/매도 체결, 수수료·세금·실현손익,
  11. /// 시세 부재 거부+예약 환원, 재실행 멱등, 좌수 NAV·누적수익률·MDD, 상폐 강제청산 (d4 M2).
  12. /// LocalDB(antooza_test) 공유 — 각 테스트는 고유 종목코드(95000x)/회원으로 격리한다.
  13. /// </summary>
  14. [TestClass]
  15. public sealed class PaperTradingBatchTests
  16. {
  17. private static Func<IAppDbContext> Factory => () => TestDb.Create();
  18. private static async Task<Stock> CreateStockAsync(AppDbContext db, string code, string name = "테스트종목", bool delisted = false)
  19. {
  20. var stock = Stock.Create(code, name, StockMarket.KOSPI, new DateOnly(2020, 1, 2));
  21. if (delisted)
  22. {
  23. stock.MarkDelisted(new DateOnly(2026, 1, 2));
  24. }
  25. await db.Stock.AddAsync(stock);
  26. await db.SaveChangesAsync(default);
  27. return stock;
  28. }
  29. // Create(stockID, tradingDate, open, high, low, close, volume, amount, priceChange, changeRate)
  30. private static async Task AddDailyPriceAsync(AppDbContext db, int stockID, DateOnly date, int open, int close)
  31. {
  32. 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));
  33. await db.SaveChangesAsync(default);
  34. }
  35. [TestMethod]
  36. public async Task BuyFill_AtOpen_SettlesReserveAndCreatesPosition()
  37. {
  38. // 기대: 매수 시가 체결 → 예약금 정산(미사용분 환급) + 포지션 생성(평균단가) + 수수료 기록.
  39. using var db = TestDb.Create();
  40. var memberID = await TestDb.CreateMemberAsync(db);
  41. var account = PaperAccount.Create(memberID);
  42. account.Deposit(1_000_000m, 1_000_000m);
  43. account.ReserveForBuy(135_150m); // ceil(10000*10*1.35)=135000 + fee 150
  44. await db.PaperAccount.AddAsync(account);
  45. await db.SaveChangesAsync(default);
  46. var stock = await CreateStockAsync(db, "950001");
  47. var targetDate = new DateOnly(2026, 6, 10);
  48. await AddDailyPriceAsync(db, stock.ID, targetDate, open: 10000, close: 11000);
  49. var order = PaperOrder.Create(account.ID, "950001", PaperOrderSide.Buy, PaperFillRule.Open, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
  50. await db.PaperOrder.AddAsync(order);
  51. await db.SaveChangesAsync(default);
  52. await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
  53. using var verify = TestDb.Create();
  54. var o = await verify.PaperOrder.FindAsync(order.ID);
  55. Assert.AreEqual(PaperOrderStatus.Filled, o!.Status);
  56. var acc = await verify.PaperAccount.FindAsync(account.ID);
  57. Assert.AreEqual(899_850m, acc!.Token, "1,000,000 - (gross 100,000 + fee 150) = 899,850");
  58. Assert.AreEqual(0m, acc.ReservedToken);
  59. Assert.AreEqual(1_000_000m, acc.Units, "매매는 좌수 불변");
  60. var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950001");
  61. Assert.AreEqual(10, pos.Quantity);
  62. Assert.AreEqual(10000m, pos.AvgPrice);
  63. var fill = verify.PaperFill.Single(c => c.OrderID == order.ID);
  64. Assert.AreEqual(10000m, fill.Price);
  65. Assert.AreEqual(150m, fill.Fee);
  66. Assert.AreEqual(100_000m, fill.Amount);
  67. Assert.IsNull(fill.RealizedPnL);
  68. }
  69. [TestMethod]
  70. public async Task SellFill_RealizesPnLAndCreditsProceeds()
  71. {
  72. // 기대: 매도 종가 체결 → 실현손익 = (체결가-평단)*수량 - 수수료 - 세금, 대금 입금, 포지션 차감.
  73. using var db = TestDb.Create();
  74. var memberID = await TestDb.CreateMemberAsync(db);
  75. var account = PaperAccount.Create(memberID);
  76. account.Deposit(100_000m, 100_000m);
  77. await db.PaperAccount.AddAsync(account);
  78. await db.SaveChangesAsync(default);
  79. var stock = await CreateStockAsync(db, "950002");
  80. var position = PaperPosition.Create(account.ID, "950002", 10, 10000m);
  81. position.ReserveSell(10);
  82. await db.PaperPosition.AddAsync(position);
  83. await db.SaveChangesAsync(default);
  84. var targetDate = new DateOnly(2026, 6, 13);
  85. await AddDailyPriceAsync(db, stock.ID, targetDate, open: 11000, close: 12000);
  86. var order = PaperOrder.Create(account.ID, "950002", PaperOrderSide.Sell, PaperFillRule.Close, 10, 0m, targetDate, DateTime.UtcNow.AddDays(1));
  87. await db.PaperOrder.AddAsync(order);
  88. await db.SaveChangesAsync(default);
  89. await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
  90. using var verify = TestDb.Create();
  91. var o = await verify.PaperOrder.FindAsync(order.ID);
  92. Assert.AreEqual(PaperOrderStatus.Filled, o!.Status);
  93. var acc = await verify.PaperAccount.FindAsync(account.ID);
  94. // gross 120,000; fee 180; tax 216; proceeds 119,604; token 100,000 + 119,604
  95. Assert.AreEqual(219_604m, acc!.Token);
  96. var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950002");
  97. Assert.AreEqual(0, pos.Quantity);
  98. Assert.AreEqual(0, pos.ReservedQuantity);
  99. var fill = verify.PaperFill.Single(c => c.OrderID == order.ID);
  100. Assert.AreEqual(12000m, fill.Price);
  101. Assert.AreEqual(180m, fill.Fee);
  102. Assert.AreEqual(216m, fill.Tax);
  103. Assert.AreEqual(19_604m, fill.RealizedPnL, "(12000-10000)*10 - 180 - 216 = 19,604");
  104. }
  105. [TestMethod]
  106. public async Task BuyFill_NoTargetPrice_RejectsAndReleasesReserve()
  107. {
  108. // 기대: TargetDate 시세가 없으면 거부("시세 없음") + 예약금 전액 환원.
  109. using var db = TestDb.Create();
  110. var memberID = await TestDb.CreateMemberAsync(db);
  111. var account = PaperAccount.Create(memberID);
  112. account.Deposit(1_000_000m, 1_000_000m);
  113. account.ReserveForBuy(135_150m);
  114. await db.PaperAccount.AddAsync(account);
  115. await db.SaveChangesAsync(default);
  116. await CreateStockAsync(db, "950003"); // 시세 미적재
  117. var targetDate = new DateOnly(2026, 6, 12);
  118. var order = PaperOrder.Create(account.ID, "950003", PaperOrderSide.Buy, PaperFillRule.Close, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
  119. await db.PaperOrder.AddAsync(order);
  120. await db.SaveChangesAsync(default);
  121. await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
  122. using var verify = TestDb.Create();
  123. var o = await verify.PaperOrder.FindAsync(order.ID);
  124. Assert.AreEqual(PaperOrderStatus.Rejected, o!.Status);
  125. Assert.AreEqual("시세 없음", o.RejectReason);
  126. var acc = await verify.PaperAccount.FindAsync(account.ID);
  127. Assert.AreEqual(1_000_000m, acc!.Token);
  128. Assert.AreEqual(0m, acc.ReservedToken);
  129. Assert.IsFalse(verify.PaperFill.Any(c => c.OrderID == order.ID));
  130. }
  131. [TestMethod]
  132. public async Task Fill_ReRun_IsIdempotent()
  133. {
  134. // 기대: 동일 loadedDate 로 두 번 실행해도 이중 체결되지 않는다 (PaperFill OrderID UNIQUE).
  135. using var db = TestDb.Create();
  136. var memberID = await TestDb.CreateMemberAsync(db);
  137. var account = PaperAccount.Create(memberID);
  138. account.Deposit(1_000_000m, 1_000_000m);
  139. account.ReserveForBuy(135_150m);
  140. await db.PaperAccount.AddAsync(account);
  141. await db.SaveChangesAsync(default);
  142. var stock = await CreateStockAsync(db, "950004");
  143. var targetDate = new DateOnly(2026, 6, 11);
  144. await AddDailyPriceAsync(db, stock.ID, targetDate, open: 10000, close: 10000);
  145. var order = PaperOrder.Create(account.ID, "950004", PaperOrderSide.Buy, PaperFillRule.Open, 10, 135_150m, targetDate, DateTime.UtcNow.AddDays(1));
  146. await db.PaperOrder.AddAsync(order);
  147. await db.SaveChangesAsync(default);
  148. await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
  149. await PaperBatchEngine.RunFillPassAsync(Factory, targetDate, default);
  150. using var verify = TestDb.Create();
  151. Assert.AreEqual(1, verify.PaperFill.Count(c => c.OrderID == order.ID), "재실행해도 체결은 1건");
  152. var acc = await verify.PaperAccount.FindAsync(account.ID);
  153. Assert.AreEqual(899_850m, acc!.Token);
  154. var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950004");
  155. Assert.AreEqual(10, pos.Quantity, "수량이 20 으로 이중 반영되지 않는다");
  156. }
  157. [TestMethod]
  158. public async Task Snapshot_NavCumReturnAndMdd_AcrossTwoDates()
  159. {
  160. // 기대: 좌수 불변 상태에서 포지션 평가 하락이 nav/누적수익률/MDD 에 반영된다.
  161. using var db = TestDb.Create();
  162. var memberID = await TestDb.CreateMemberAsync(db);
  163. var account = PaperAccount.Create(memberID);
  164. account.Deposit(1_000_000m, 1_000_000m);
  165. account.ReserveForBuy(500_000m);
  166. account.SettleBuy(500_000m, 500_000m); // 현금 500,000, 포지션 취득 모사
  167. await db.PaperAccount.AddAsync(account);
  168. await db.SaveChangesAsync(default);
  169. var stock = await CreateStockAsync(db, "950005");
  170. var position = PaperPosition.Create(account.ID, "950005", 100, 5000m);
  171. await db.PaperPosition.AddAsync(position);
  172. await db.SaveChangesAsync(default);
  173. var date1 = new DateOnly(2026, 6, 1);
  174. await AddDailyPriceAsync(db, stock.ID, date1, open: 5000, close: 5000);
  175. using (var snap1 = TestDb.Create())
  176. {
  177. await PaperBatchEngine.RunSnapshotPassAsync(snap1, date1, 15, 18, default);
  178. }
  179. var date2 = new DateOnly(2026, 6, 2);
  180. await AddDailyPriceAsync(db, stock.ID, date2, open: 4000, close: 4000);
  181. using (var snap2 = TestDb.Create())
  182. {
  183. await PaperBatchEngine.RunSnapshotPassAsync(snap2, date2, 15, 18, default);
  184. }
  185. using var verify = TestDb.Create();
  186. var s1 = verify.PaperDailySnapshot.Single(c => c.AccountID == account.ID && c.TradeDate == date1);
  187. Assert.AreEqual(1m, s1.UnitNav);
  188. Assert.AreEqual(0, s1.CumReturnBp);
  189. Assert.AreEqual(1_000_000m, s1.PeakEquity);
  190. Assert.AreEqual(0, s1.MddBp);
  191. var s2 = verify.PaperDailySnapshot.Single(c => c.AccountID == account.ID && c.TradeDate == date2);
  192. Assert.AreEqual(0.9m, s2.UnitNav, "equity 900,000 / units 1,000,000 = 0.9");
  193. Assert.AreEqual(-1000, s2.CumReturnBp);
  194. Assert.AreEqual(-1000, s2.DailyReturnBp);
  195. Assert.AreEqual(1_000_000m, s2.PeakEquity);
  196. Assert.AreEqual(1000, s2.MddBp, "(1,000,000 - 900,000)/1,000,000 = 10% = 1000bp");
  197. }
  198. [TestMethod]
  199. public async Task Snapshot_LiquidatesDelistedPositionAtLastClose()
  200. {
  201. // 기대: 상폐(IsActive=false) 종목 보유분을 최종 거래일 종가로 강제청산 후 대금 입금 (d4 §⑨).
  202. using var db = TestDb.Create();
  203. var memberID = await TestDb.CreateMemberAsync(db);
  204. var account = PaperAccount.Create(memberID);
  205. account.Deposit(1_000_000m, 1_000_000m);
  206. account.ReserveForBuy(500_000m);
  207. account.SettleBuy(500_000m, 500_000m); // 현금 500,000
  208. await db.PaperAccount.AddAsync(account);
  209. await db.SaveChangesAsync(default);
  210. var stock = await CreateStockAsync(db, "950006");
  211. var position = PaperPosition.Create(account.ID, "950006", 100, 5000m);
  212. await db.PaperPosition.AddAsync(position);
  213. await db.SaveChangesAsync(default);
  214. var lastDate = new DateOnly(2026, 6, 20);
  215. await AddDailyPriceAsync(db, stock.ID, lastDate, open: 6000, close: 6000);
  216. var s = await db.Stock.FindAsync(stock.ID);
  217. s!.MarkDelisted(lastDate);
  218. await db.SaveChangesAsync(default);
  219. var tradeDate = new DateOnly(2026, 6, 21);
  220. using (var snap = TestDb.Create())
  221. {
  222. await PaperBatchEngine.RunSnapshotPassAsync(snap, tradeDate, 15, 18, default);
  223. }
  224. using var verify = TestDb.Create();
  225. var pos = verify.PaperPosition.Single(c => c.AccountID == account.ID && c.StockCode == "950006");
  226. Assert.AreEqual(0, pos.Quantity, "상폐 포지션은 강제청산되어 0");
  227. var acc = await verify.PaperAccount.FindAsync(account.ID);
  228. // gross 600,000; fee 900; tax 1,080; proceeds 598,020; token 500,000 + 598,020
  229. Assert.AreEqual(1_098_020m, acc!.Token);
  230. }
  231. }