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