PaperBatchEngine.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. using Application.Abstractions.Data;
  2. using Domain.Entities.Paper;
  3. using Domain.Entities.Paper.ValueObject;
  4. using Microsoft.EntityFrameworkCore;
  5. namespace Application.Helpers;
  6. /// <summary>
  7. /// 모의투자 체결·스냅샷 배치 엔진 (d4 M2 체결/스냅샷 배치 구현).
  8. /// D1 이 TargetDate 일별 시세를 적재하면 Pending 주문을 시가/종가로 기표(Fill)하고,
  9. /// 그 직후 전 계좌 좌수 NAV·수익률·MDD 를 산출해 PaperDailySnapshot 을 upsert 한다.
  10. ///
  11. /// 스케줄/폴링은 Infrastructure 의 HostedService(PaperFillService)가 담당하고, 여기서는
  12. /// 순수 배치 로직만 다뤄 IAppDbContext 로 단위 테스트가 가능하도록 분리했다.
  13. ///
  14. /// 체결 규칙(d4 §③):
  15. /// • 체결가 = FillRule==Open ? Open : Close (당일 OHLCV 단일가 전량 체결, 부분체결 없음)
  16. /// • 매수: fee = floor(gross×FeeRateBp/10000), cost = gross + fee. cost > 예약금이면 거부 + 전액 환원.
  17. /// 정산 = SettleBuy(예약금, cost) → 예약 해제 + 미사용분 환급. 평균단가법으로 포지션 갱신.
  18. /// • 매도: fee = floor(gross×FeeRateBp/10000), tax = floor(gross×TaxRateBp/10000), proceeds = gross - fee - tax.
  19. /// realizedPnL = (체결가 - AvgPrice)×수량 - fee - tax. 예약/보유 수량 차감 + 대금 입금.
  20. /// • 시세 부재(휴장·상폐·미적재) → Rejected("시세 없음") + 예약 환원.
  21. /// • 좌수 불변 — 매매는 Equity 만 변동, nav 상승률 = 순수 운용 수익률.
  22. /// • 재실행 멱등 — PaperFill(OrderID UNIQUE) 존재 시 skip, Pending 아니면 skip.
  23. /// </summary>
  24. public static class PaperBatchEngine
  25. {
  26. /// <summary>체결 결과 요약 (배치 로그/테스트용).</summary>
  27. public readonly record struct FillPassResult(int Considered, int Filled, int Rejected, int Skipped, int Conflicts);
  28. /// <summary>스냅샷 결과 요약 (배치 로그/테스트용).</summary>
  29. public readonly record struct SnapshotPassResult(int Liquidated, int Snapshots);
  30. /// <summary>
  31. /// 체결 패스 — loadedDate 이하 TargetDate 의 Pending 주문을 오래된 순으로 기표한다.
  32. /// 각 주문은 ctxFactory 로 만든 독립 컨텍스트에서 처리 → 주문 단위 트랜잭션 + 낙관적 동시성 격리.
  33. /// 한 주문의 DbUpdateConcurrencyException 은 건너뛰고(다음 실행 재시도) 패스 전체를 멈추지 않는다.
  34. /// </summary>
  35. public static async Task<FillPassResult> RunFillPassAsync(
  36. Func<IAppDbContext> ctxFactory,
  37. DateOnly loadedDate,
  38. CancellationToken ct)
  39. {
  40. int feeRateBp;
  41. int taxRateBp;
  42. List<int> orderIDs;
  43. // 대상 주문 목록 + Config 는 읽기 전용 컨텍스트에서 한 번만 로드
  44. var readCtx = ctxFactory();
  45. await using (readCtx.ConfigureAwait(false))
  46. {
  47. var paper = await readCtx.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
  48. feeRateBp = paper?.FeeRateBp ?? 15;
  49. taxRateBp = paper?.TaxRateBp ?? 18;
  50. orderIDs = await readCtx.PaperOrder.AsNoTracking()
  51. .Where(c => c.Status == PaperOrderStatus.Pending && c.TargetDate <= loadedDate)
  52. .OrderBy(c => c.CreatedAt).ThenBy(c => c.ID)
  53. .Select(c => c.ID)
  54. .ToListAsync(ct);
  55. }
  56. var filled = 0;
  57. var rejected = 0;
  58. var skipped = 0;
  59. var conflicts = 0;
  60. foreach (var orderID in orderIDs)
  61. {
  62. if (ct.IsCancellationRequested)
  63. {
  64. break;
  65. }
  66. var outcome = await FillOneAsync(ctxFactory, orderID, feeRateBp, taxRateBp, ct);
  67. switch (outcome)
  68. {
  69. case FillOutcome.Filled:
  70. filled++;
  71. break;
  72. case FillOutcome.Rejected:
  73. rejected++;
  74. break;
  75. case FillOutcome.Conflict:
  76. conflicts++;
  77. break;
  78. default:
  79. skipped++;
  80. break;
  81. }
  82. }
  83. return new FillPassResult(orderIDs.Count, filled, rejected, skipped, conflicts);
  84. }
  85. private enum FillOutcome { Skipped, Filled, Rejected, Conflict }
  86. private static async Task<FillOutcome> FillOneAsync(
  87. Func<IAppDbContext> ctxFactory,
  88. int orderID,
  89. int feeRateBp,
  90. int taxRateBp,
  91. CancellationToken ct)
  92. {
  93. var db = ctxFactory();
  94. await using (db.ConfigureAwait(false))
  95. {
  96. var order = await db.PaperOrder.FirstOrDefaultAsync(c => c.ID == orderID, ct);
  97. if (order is null || order.Status != PaperOrderStatus.Pending)
  98. {
  99. return FillOutcome.Skipped;
  100. }
  101. // 멱등 — 이미 체결(PaperFill) 존재 시 skip
  102. if (await db.PaperFill.AnyAsync(c => c.OrderID == orderID, ct))
  103. {
  104. return FillOutcome.Skipped;
  105. }
  106. var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.ID == order.AccountID, ct);
  107. if (account is null)
  108. {
  109. return FillOutcome.Skipped;
  110. }
  111. // TargetDate 시세 조회 (Stock.Code → Stock.ID → StockDailyPrice)
  112. var stockID = await db.Stock.AsNoTracking().Where(c => c.Code == order.StockCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct);
  113. StockPrice? price = null;
  114. if (stockID is not null)
  115. {
  116. price = await db.StockDailyPrice.AsNoTracking()
  117. .Where(c => c.StockID == stockID.Value && c.TradingDate == order.TargetDate)
  118. .Select(c => (StockPrice?)new StockPrice(c.Open, c.Close))
  119. .FirstOrDefaultAsync(ct);
  120. }
  121. // 시세 부재 → 거부 + 예약 환원
  122. if (price is null)
  123. {
  124. RejectAndRelease(order, account, await LoadPositionAsync(db, order, ct), "시세 없음");
  125. return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict;
  126. }
  127. var fillPrice = order.FillRule == PaperFillRule.Open ? price.Value.Open : price.Value.Close;
  128. if (fillPrice <= 0)
  129. {
  130. RejectAndRelease(order, account, await LoadPositionAsync(db, order, ct), "체결가 오류");
  131. return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict;
  132. }
  133. var gross = fillPrice * order.Quantity;
  134. var fee = Math.Floor(gross * feeRateBp / 10000m);
  135. if (order.Side == PaperOrderSide.Buy)
  136. {
  137. var cost = gross + fee;
  138. // 예약금(±35% 버퍼) 초과 → 거부 + 예약 전액 환원
  139. if (cost > order.ReservedAmount)
  140. {
  141. order.MarkRejected("예약금 초과");
  142. if (order.ReservedAmount > 0)
  143. {
  144. account.ReleaseBuyReserve(order.ReservedAmount);
  145. }
  146. return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict;
  147. }
  148. account.SettleBuy(order.ReservedAmount, cost);
  149. var position = await LoadPositionAsync(db, order, ct);
  150. if (position is null)
  151. {
  152. position = PaperPosition.Create(account.ID, order.StockCode, 0, 0m);
  153. db.PaperPosition.Add(position);
  154. }
  155. position.ApplyBuy(order.Quantity, fillPrice);
  156. db.PaperFill.Add(PaperFill.Create(order.ID, fillPrice, order.Quantity, fee, 0m, gross, order.TargetDate));
  157. order.MarkFilled();
  158. }
  159. else
  160. {
  161. var tax = Math.Floor(gross * taxRateBp / 10000m);
  162. var proceeds = gross - fee - tax;
  163. var position = await LoadPositionAsync(db, order, ct);
  164. if (position is null || position.Quantity < order.Quantity || position.ReservedQuantity < order.Quantity)
  165. {
  166. // 매도 체결 시점에 포지션이 어긋나면(비정상) 거부 + 예약 환원 시도
  167. RejectAndRelease(order, account, position, "보유 수량 부족");
  168. return await CommitAsync(db, ct) ? FillOutcome.Rejected : FillOutcome.Conflict;
  169. }
  170. var realizedPnL = (fillPrice - position.AvgPrice) * order.Quantity - fee - tax;
  171. position.ApplySell(order.Quantity);
  172. account.AddCash(proceeds);
  173. db.PaperFill.Add(PaperFill.Create(order.ID, fillPrice, order.Quantity, fee, tax, gross, order.TargetDate, realizedPnL));
  174. order.MarkFilled();
  175. }
  176. return await CommitAsync(db, ct) ? FillOutcome.Filled : FillOutcome.Conflict;
  177. }
  178. }
  179. /// <summary>거부 처리 + 사이드별 예약 환원 (매수: 예약금, 매도: 예약 수량).</summary>
  180. private static void RejectAndRelease(PaperOrder order, PaperAccount account, PaperPosition? position, string reason)
  181. {
  182. order.MarkRejected(reason);
  183. if (order.Side == PaperOrderSide.Buy)
  184. {
  185. if (order.ReservedAmount > 0)
  186. {
  187. account.ReleaseBuyReserve(order.ReservedAmount);
  188. }
  189. }
  190. else if (position is not null && position.ReservedQuantity >= order.Quantity)
  191. {
  192. position.ReleaseSellReserve(order.Quantity);
  193. }
  194. }
  195. private static async Task<PaperPosition?> LoadPositionAsync(IAppDbContext db, PaperOrder order, CancellationToken ct)
  196. {
  197. return await db.PaperPosition.FirstOrDefaultAsync(c => c.AccountID == order.AccountID && c.StockCode == order.StockCode, ct);
  198. }
  199. /// <summary>SaveChanges — 낙관적 동시성 충돌은 false 로 신호(다음 실행 재시도).</summary>
  200. private static async Task<bool> CommitAsync(IAppDbContext db, CancellationToken ct)
  201. {
  202. try
  203. {
  204. await db.SaveChangesAsync(ct);
  205. return true;
  206. }
  207. catch (DbUpdateConcurrencyException)
  208. {
  209. return false;
  210. }
  211. }
  212. private readonly record struct StockPrice(int Open, int Close);
  213. /// <summary>
  214. /// 스냅샷 패스 — tradeDate 기준 (1) 상폐 포지션 강제청산 (2) 전 계좌 좌수 NAV·수익률·MDD upsert.
  215. /// 단일 컨텍스트에서 수행하며, 상폐 청산은 시스템 주문/체결로 정상 회계 흐름을 태운다.
  216. /// 재실행 시 동일 (AccountID, TradeDate) 스냅샷을 덮어쓰므로 멱등하다.
  217. /// </summary>
  218. public static async Task<SnapshotPassResult> RunSnapshotPassAsync(
  219. IAppDbContext db,
  220. DateOnly tradeDate,
  221. int feeRateBp,
  222. int taxRateBp,
  223. CancellationToken ct)
  224. {
  225. var liquidated = await LiquidateDelistedAsync(db, tradeDate, feeRateBp, taxRateBp, ct);
  226. // 활동 계좌 = 좌수 보유 or 포지션 보유 계좌
  227. var accounts = await db.PaperAccount.ToListAsync(ct);
  228. var snapshots = 0;
  229. foreach (var account in accounts)
  230. {
  231. if (ct.IsCancellationRequested)
  232. {
  233. break;
  234. }
  235. var positions = await db.PaperPosition.AsNoTracking()
  236. .Where(c => c.AccountID == account.ID && c.Quantity > 0)
  237. .Select(c => new { c.StockCode, c.Quantity })
  238. .ToListAsync(ct);
  239. // 활동이 없고 좌수도 없으면 스냅샷 skip
  240. if (account.Units <= 0 && positions.Count == 0)
  241. {
  242. continue;
  243. }
  244. var closes = await GetClosesAtAsync(db, positions.Select(c => c.StockCode).Distinct().ToList(), tradeDate, ct);
  245. var positionsValue = 0m;
  246. foreach (var p in positions)
  247. {
  248. if (closes.TryGetValue(p.StockCode, out var close))
  249. {
  250. positionsValue += close * p.Quantity;
  251. }
  252. }
  253. var token = account.Token + account.ReservedToken;
  254. var equity = token + positionsValue;
  255. var nav = account.Units > 0 ? equity / account.Units : 1m;
  256. var prior = await db.PaperDailySnapshot.AsNoTracking()
  257. .Where(c => c.AccountID == account.ID && c.TradeDate < tradeDate)
  258. .OrderByDescending(c => c.TradeDate)
  259. .Select(c => new { c.UnitNav, c.PeakEquity, c.MddBp })
  260. .FirstOrDefaultAsync(ct);
  261. var priorNav = prior?.UnitNav ?? 1m;
  262. var priorPeak = prior?.PeakEquity ?? 0m;
  263. var priorMdd = prior?.MddBp ?? 0;
  264. var dailyReturnBp = priorNav > 0 ? (int)Math.Round((nav / priorNav - 1m) * 10000m) : 0;
  265. var cumReturnBp = (int)Math.Round((nav - 1m) * 10000m);
  266. var peakEquity = Math.Max(priorPeak, equity);
  267. var drawdownBp = peakEquity > 0 ? (int)Math.Round((peakEquity - equity) / peakEquity * 10000m) : 0;
  268. var mddBp = Math.Max(priorMdd, drawdownBp);
  269. var fillCountCum = await db.PaperFill.AsNoTracking()
  270. .CountAsync(c => c.Order!.AccountID == account.ID && c.PriceDate <= tradeDate, ct);
  271. var existing = await db.PaperDailySnapshot.FirstOrDefaultAsync(c => c.AccountID == account.ID && c.TradeDate == tradeDate, ct);
  272. if (existing is null)
  273. {
  274. db.PaperDailySnapshot.Add(PaperDailySnapshot.Create(account.ID, tradeDate, token, positionsValue, equity, nav, dailyReturnBp, cumReturnBp, peakEquity, mddBp, fillCountCum));
  275. }
  276. else
  277. {
  278. existing.Update(token, positionsValue, equity, nav, dailyReturnBp, cumReturnBp, peakEquity, mddBp, fillCountCum);
  279. }
  280. snapshots++;
  281. }
  282. await db.SaveChangesAsync(ct);
  283. return new SnapshotPassResult(liquidated, snapshots);
  284. }
  285. /// <summary>
  286. /// 상폐(IsActive=false) 종목 보유 포지션을 최종 거래일 종가로 강제 청산한다 (d4 §⑨).
  287. /// 시스템 매도 주문(Filled) + 체결(PaperFill)을 생성해 정상 회계 흐름을 태운다 — 멱등:
  288. /// 이미 청산된(Quantity=0) 포지션은 대상이 아니다.
  289. /// </summary>
  290. private static async Task<int> LiquidateDelistedAsync(
  291. IAppDbContext db,
  292. DateOnly tradeDate,
  293. int feeRateBp,
  294. int taxRateBp,
  295. CancellationToken ct)
  296. {
  297. var delistedCodes = await db.Stock.AsNoTracking().Where(c => !c.IsActive).Select(c => c.Code).ToListAsync(ct);
  298. if (delistedCodes.Count == 0)
  299. {
  300. return 0;
  301. }
  302. var positions = await db.PaperPosition
  303. .Where(c => c.Quantity > 0 && delistedCodes.Contains(c.StockCode))
  304. .ToListAsync(ct);
  305. if (positions.Count == 0)
  306. {
  307. return 0;
  308. }
  309. var count = 0;
  310. foreach (var position in positions)
  311. {
  312. var stockID = await db.Stock.AsNoTracking().Where(c => c.Code == position.StockCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct);
  313. if (stockID is null)
  314. {
  315. continue;
  316. }
  317. var last = await db.StockDailyPrice.AsNoTracking()
  318. .Where(c => c.StockID == stockID.Value)
  319. .OrderByDescending(c => c.TradingDate)
  320. .Select(c => new { c.Close, c.TradingDate })
  321. .FirstOrDefaultAsync(ct);
  322. var lastClose = last?.Close ?? 0;
  323. var lastDate = last?.TradingDate ?? tradeDate;
  324. var qty = position.Quantity;
  325. var gross = (decimal)lastClose * qty;
  326. var fee = Math.Floor(gross * feeRateBp / 10000m);
  327. var tax = Math.Floor(gross * taxRateBp / 10000m);
  328. var proceeds = gross - fee - tax;
  329. if (proceeds < 0)
  330. {
  331. proceeds = 0;
  332. }
  333. var realizedPnL = (lastClose - position.AvgPrice) * qty - fee - tax;
  334. var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.ID == position.AccountID, ct);
  335. if (account is null)
  336. {
  337. continue;
  338. }
  339. // 시스템 매도 주문(예약 없음, 즉시 Filled) → 정상 회계 흐름
  340. var order = PaperOrder.Create(account.ID, position.StockCode, PaperOrderSide.Sell, PaperFillRule.Close, qty, 0m, lastDate, DateTime.UtcNow);
  341. order.MarkFilled();
  342. db.PaperOrder.Add(order);
  343. position.ForceLiquidate();
  344. account.AddCash(proceeds);
  345. db.PaperFill.Add(PaperFill.CreateFor(order, lastClose, qty, fee, tax, gross, lastDate, realizedPnL));
  346. count++;
  347. }
  348. await db.SaveChangesAsync(ct);
  349. return count;
  350. }
  351. /// <summary>지정 거래일(tradeDate) 종가 맵 — 해당일 시세가 없으면 가장 최근 종가로 fallback.</summary>
  352. private static async Task<Dictionary<string, decimal>> GetClosesAtAsync(
  353. IAppDbContext db,
  354. IReadOnlyCollection<string> stockCodes,
  355. DateOnly tradeDate,
  356. CancellationToken ct)
  357. {
  358. var result = new Dictionary<string, decimal>();
  359. if (stockCodes.Count == 0)
  360. {
  361. return result;
  362. }
  363. var idByCode = await db.Stock.AsNoTracking().Where(c => stockCodes.Contains(c.Code)).Select(c => new { c.Code, c.ID }).ToListAsync(ct);
  364. foreach (var s in idByCode)
  365. {
  366. var atDate = await db.StockDailyPrice.AsNoTracking()
  367. .Where(c => c.StockID == s.ID && c.TradingDate <= tradeDate)
  368. .OrderByDescending(c => c.TradingDate)
  369. .Select(c => (int?)c.Close)
  370. .FirstOrDefaultAsync(ct);
  371. if (atDate.HasValue)
  372. {
  373. result[s.Code] = atDate.Value;
  374. }
  375. }
  376. return result;
  377. }
  378. }