using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Helpers; using Domain.Entities.Paper.ValueObject; using Domain.Entities.Stocks.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using PaperOrderEntity = Domain.Entities.Paper.PaperOrder; namespace Application.Features.Api.Paper.PlaceOrder; /// /// 모의투자 주문 접수 — FillRule/TargetDate/CancelableUntil 서버 산정 + 예약/상한 검증 (d4 §③/§⑤). /// 매수: ReservedAmount = ceil(종가×수량×1.35) + 예상수수료, 1주문 상한(Equity×OrderMaxPctBp) 검증, 계좌 예약. /// 매도: 포지션 가용 수량 검증 후 매도 예약. 실제 기표는 배치(M2). /// internal sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct); if (paper is null || !paper.Enabled) { return Result.Failure(Error.Problem("Paper.Disabled", "모의투자가 비활성화되어 있습니다.")); } if (request.Quantity <= 0) { return Result.Failure(Error.Problem("Paper.InvalidQuantity", "수량은 1 이상이어야 합니다.")); } var code = request.StockCode?.Trim() ?? ""; if (code.Length != 6) { return Result.Failure(Error.Problem("Paper.InvalidCode", "종목코드가 올바르지 않습니다.")); } // 종목 존재 + 거래 상태 검증 var stock = await db.Stock.AsNoTracking() .Where(c => c.Code == code) .Select(c => new { c.ID, c.TradingStatus, c.IsActive }) .FirstOrDefaultAsync(ct); if (stock is null || !stock.IsActive) { return Result.Failure(Error.NotFound("Paper.StockNotFound", "종목을 찾을 수 없습니다.")); } if (stock.TradingStatus != TradingStatus.Normal) { return Result.Failure(Error.Problem("Paper.StockNotTradable", "거래정지 또는 관리종목은 주문할 수 없습니다.")); } var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (account is null) { return Result.Failure(Error.NotFound("Paper.NoAccount", "모의투자 계좌가 없습니다. 먼저 입금해 주세요.")); } // 최근 종가 var latestClose = await PaperValuation.GetLatestCloseAsync(db, code, ct); if (latestClose is null || latestClose.Value <= 0) { return Result.Failure(Error.Problem("Paper.NoPrice", "종목 시세가 없어 주문할 수 없습니다.")); } // 체결 계획 (KST + 휴장일) var holidayList = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct); var holidays = holidayList.ToHashSet(); var nowKst = PaperTradingClock.NowKst(); var (fillRule, targetDate, cancelableUntilKst) = PaperTradingClock.Plan(nowKst, holidays); var cancelableUntilUtc = PaperTradingClock.ToUtc(cancelableUntilKst); var close = latestClose.Value; var orderNotional = close * request.Quantity; decimal reservedAmount; if (request.Side == PaperOrderSide.Buy) { // 1주문 상한: 주문금액 ≤ Equity × OrderMaxPctBp / 10000 var positions = await db.PaperPosition.AsNoTracking() .Where(c => c.AccountID == account.ID && c.Quantity > 0) .Select(c => new { c.StockCode, c.Quantity }) .ToListAsync(ct); var codes = positions.Select(c => c.StockCode).Distinct().ToList(); var latestCloses = await PaperValuation.GetLatestClosesAsync(db, codes, ct); var equity = PaperValuation.ComputeEquity(account.Token, account.ReservedToken, positions.Select(c => (c.StockCode, c.Quantity)), latestCloses); var maxNotional = equity * paper.OrderMaxPctBp / 10000m; if (orderNotional > maxNotional) { return Result.Failure(Error.Problem("Paper.OrderTooLarge", $"1주문 한도({paper.OrderMaxPctBp / 100m:0.##}% = {(long)maxNotional:N0} 토큰)를 초과합니다.")); } // 매수 예약금 = ceil(종가 × 수량 × 1.35) + 예상수수료 (가격제한폭 ±30% + 연휴 갭 여유) var priceReserve = Math.Ceiling(orderNotional * 1.35m); var estFee = Math.Floor(orderNotional * paper.FeeRateBp / 10000m); reservedAmount = priceReserve + estFee; if (account.Token < reservedAmount) { return Result.Failure(Error.Conflict("Paper.InsufficientToken", $"잔액이 부족합니다. 예약 필요: {(long)reservedAmount:N0} / 가용: {(long)account.Token:N0}.")); } account.ReserveForBuy(reservedAmount); } else { // 매도: 포지션 가용 수량 검증 var position = await db.PaperPosition.FirstOrDefaultAsync(c => c.AccountID == account.ID && c.StockCode == code, ct); if (position is null || position.Quantity - position.ReservedQuantity < request.Quantity) { return Result.Failure(Error.Conflict("Paper.InsufficientPosition", "매도 가능한 보유 수량이 부족합니다.")); } position.ReserveSell(request.Quantity); reservedAmount = 0; } var order = PaperOrderEntity.Create( account.ID, code, request.Side, fillRule, request.Quantity, reservedAmount, targetDate, cancelableUntilUtc); db.PaperOrder.Add(order); await db.SaveChangesAsync(ct); return Result.Success(new Response( order.ID, order.Side.ToString(), order.FillRule.ToString(), order.TargetDate, order.CancelableUntil, order.Quantity, order.ReservedAmount)); } }