Handler.cs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Helpers;
  4. using Domain.Entities.Paper.ValueObject;
  5. using Domain.Entities.Stocks.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using SharedKernel.Results;
  8. using PaperOrderEntity = Domain.Entities.Paper.PaperOrder;
  9. namespace Application.Features.Api.Paper.PlaceOrder;
  10. /// <summary>
  11. /// 모의투자 주문 접수 — FillRule/TargetDate/CancelableUntil 서버 산정 + 예약/상한 검증 (d4 §③/§⑤).
  12. /// 매수: ReservedAmount = ceil(종가×수량×1.35) + 예상수수료, 1주문 상한(Equity×OrderMaxPctBp) 검증, 계좌 예약.
  13. /// 매도: 포지션 가용 수량 검증 후 매도 예약. 실제 기표는 배치(M2).
  14. /// </summary>
  15. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  16. {
  17. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  18. {
  19. var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
  20. if (paper is null || !paper.Enabled)
  21. {
  22. return Result.Failure<Response>(Error.Problem("Paper.Disabled", "모의투자가 비활성화되어 있습니다."));
  23. }
  24. if (request.Quantity <= 0)
  25. {
  26. return Result.Failure<Response>(Error.Problem("Paper.InvalidQuantity", "수량은 1 이상이어야 합니다."));
  27. }
  28. var code = request.StockCode?.Trim() ?? "";
  29. if (code.Length != 6)
  30. {
  31. return Result.Failure<Response>(Error.Problem("Paper.InvalidCode", "종목코드가 올바르지 않습니다."));
  32. }
  33. // 종목 존재 + 거래 상태 검증
  34. var stock = await db.Stock.AsNoTracking()
  35. .Where(c => c.Code == code)
  36. .Select(c => new { c.ID, c.TradingStatus, c.IsActive })
  37. .FirstOrDefaultAsync(ct);
  38. if (stock is null || !stock.IsActive)
  39. {
  40. return Result.Failure<Response>(Error.NotFound("Paper.StockNotFound", "종목을 찾을 수 없습니다."));
  41. }
  42. if (stock.TradingStatus != TradingStatus.Normal)
  43. {
  44. return Result.Failure<Response>(Error.Problem("Paper.StockNotTradable", "거래정지 또는 관리종목은 주문할 수 없습니다."));
  45. }
  46. var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  47. if (account is null)
  48. {
  49. return Result.Failure<Response>(Error.NotFound("Paper.NoAccount", "모의투자 계좌가 없습니다. 먼저 입금해 주세요."));
  50. }
  51. // 최근 종가
  52. var latestClose = await PaperValuation.GetLatestCloseAsync(db, code, ct);
  53. if (latestClose is null || latestClose.Value <= 0)
  54. {
  55. return Result.Failure<Response>(Error.Problem("Paper.NoPrice", "종목 시세가 없어 주문할 수 없습니다."));
  56. }
  57. // 체결 계획 (KST + 휴장일)
  58. var holidayList = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct);
  59. var holidays = holidayList.ToHashSet();
  60. var nowKst = PaperTradingClock.NowKst();
  61. var (fillRule, targetDate, cancelableUntilKst) = PaperTradingClock.Plan(nowKst, holidays);
  62. var cancelableUntilUtc = PaperTradingClock.ToUtc(cancelableUntilKst);
  63. var close = latestClose.Value;
  64. var orderNotional = close * request.Quantity;
  65. decimal reservedAmount;
  66. if (request.Side == PaperOrderSide.Buy)
  67. {
  68. // 1주문 상한: 주문금액 ≤ Equity × OrderMaxPctBp / 10000
  69. var positions = await db.PaperPosition.AsNoTracking()
  70. .Where(c => c.AccountID == account.ID && c.Quantity > 0)
  71. .Select(c => new { c.StockCode, c.Quantity })
  72. .ToListAsync(ct);
  73. var codes = positions.Select(c => c.StockCode).Distinct().ToList();
  74. var latestCloses = await PaperValuation.GetLatestClosesAsync(db, codes, ct);
  75. var equity = PaperValuation.ComputeEquity(account.Token, account.ReservedToken, positions.Select(c => (c.StockCode, c.Quantity)), latestCloses);
  76. var maxNotional = equity * paper.OrderMaxPctBp / 10000m;
  77. if (orderNotional > maxNotional)
  78. {
  79. return Result.Failure<Response>(Error.Problem("Paper.OrderTooLarge", $"1주문 한도({paper.OrderMaxPctBp / 100m:0.##}% = {(long)maxNotional:N0} 토큰)를 초과합니다."));
  80. }
  81. // 매수 예약금 = ceil(종가 × 수량 × 1.35) + 예상수수료 (가격제한폭 ±30% + 연휴 갭 여유)
  82. var priceReserve = Math.Ceiling(orderNotional * 1.35m);
  83. var estFee = Math.Floor(orderNotional * paper.FeeRateBp / 10000m);
  84. reservedAmount = priceReserve + estFee;
  85. if (account.Token < reservedAmount)
  86. {
  87. return Result.Failure<Response>(Error.Conflict("Paper.InsufficientToken", $"잔액이 부족합니다. 예약 필요: {(long)reservedAmount:N0} / 가용: {(long)account.Token:N0}."));
  88. }
  89. account.ReserveForBuy(reservedAmount);
  90. }
  91. else
  92. {
  93. // 매도: 포지션 가용 수량 검증
  94. var position = await db.PaperPosition.FirstOrDefaultAsync(c => c.AccountID == account.ID && c.StockCode == code, ct);
  95. if (position is null || position.Quantity - position.ReservedQuantity < request.Quantity)
  96. {
  97. return Result.Failure<Response>(Error.Conflict("Paper.InsufficientPosition", "매도 가능한 보유 수량이 부족합니다."));
  98. }
  99. position.ReserveSell(request.Quantity);
  100. reservedAmount = 0;
  101. }
  102. var order = PaperOrderEntity.Create(
  103. account.ID,
  104. code,
  105. request.Side,
  106. fillRule,
  107. request.Quantity,
  108. reservedAmount,
  109. targetDate,
  110. cancelableUntilUtc);
  111. db.PaperOrder.Add(order);
  112. await db.SaveChangesAsync(ct);
  113. return Result.Success(new Response(
  114. order.ID,
  115. order.Side,
  116. order.FillRule,
  117. order.TargetDate,
  118. order.CancelableUntil,
  119. order.Quantity,
  120. order.ReservedAmount));
  121. }
  122. }