| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- 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;
- /// <summary>
- /// 모의투자 주문 접수 — FillRule/TargetDate/CancelableUntil 서버 산정 + 예약/상한 검증 (d4 §③/§⑤).
- /// 매수: ReservedAmount = ceil(종가×수량×1.35) + 예상수수료, 1주문 상한(Equity×OrderMaxPctBp) 검증, 계좌 예약.
- /// 매도: 포지션 가용 수량 검증 후 매도 예약. 실제 기표는 배치(M2).
- /// </summary>
- internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
- {
- public async Task<Result<Response>> 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<Response>(Error.Problem("Paper.Disabled", "모의투자가 비활성화되어 있습니다."));
- }
- if (request.Quantity <= 0)
- {
- return Result.Failure<Response>(Error.Problem("Paper.InvalidQuantity", "수량은 1 이상이어야 합니다."));
- }
- var code = request.StockCode?.Trim() ?? "";
- if (code.Length != 6)
- {
- return Result.Failure<Response>(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<Response>(Error.NotFound("Paper.StockNotFound", "종목을 찾을 수 없습니다."));
- }
- if (stock.TradingStatus != TradingStatus.Normal)
- {
- return Result.Failure<Response>(Error.Problem("Paper.StockNotTradable", "거래정지 또는 관리종목은 주문할 수 없습니다."));
- }
- var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
- if (account is null)
- {
- return Result.Failure<Response>(Error.NotFound("Paper.NoAccount", "모의투자 계좌가 없습니다. 먼저 입금해 주세요."));
- }
- // 최근 종가
- var latestClose = await PaperValuation.GetLatestCloseAsync(db, code, ct);
- if (latestClose is null || latestClose.Value <= 0)
- {
- return Result.Failure<Response>(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<Response>(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<Response>(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<Response>(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));
- }
- }
|