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