using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Domain.Entities.Paper; /// /// 모의투자 보유 포지션 — (AccountID, StockCode) UNIQUE. StockCode 는 FK 없이 denorm 보관 (d0 §2.1). /// ReservedQuantity 로 이중 매도를 차단한다. /// public class PaperPosition { [ForeignKey(nameof(AccountID))] public virtual PaperAccount Account { get; private set; } = null!; [Key] public int ID { get; private set; } public int AccountID { get; private set; } /// 단축코드 (char 6) public string StockCode { get; private set; } = default!; /// 보유 수량 public int Quantity { get; private set; } /// 매도 주문으로 예약된 수량 public int ReservedQuantity { get; private set; } /// 평균 단가 (18,4) — 매수 체결 시 평균단가법 갱신 public decimal AvgPrice { get; private set; } public DateTime UpdatedAt { get; private set; } = DateTime.UtcNow; [Timestamp] public byte[] RowVersion { get; private set; } = default!; private PaperPosition() { } public static PaperPosition Create(int accountID, string stockCode, int quantity, decimal avgPrice) { if (accountID <= 0) { throw new ArgumentOutOfRangeException(nameof(accountID)); } if (stockCode is not { Length: 6 }) { throw new ArgumentException("stockCode must be 6 chars", nameof(stockCode)); } if (quantity < 0) { throw new ArgumentOutOfRangeException(nameof(quantity)); } return new PaperPosition { AccountID = accountID, StockCode = stockCode, Quantity = quantity, ReservedQuantity = 0, AvgPrice = avgPrice }; } /// 매도 주문 접수 시 수량 예약 — 가용 수량(Quantity - ReservedQuantity) 부족 시 실패. public void ReserveSell(int quantity) { if (quantity <= 0) { throw new ArgumentOutOfRangeException(nameof(quantity)); } if (Quantity - ReservedQuantity < quantity) { throw new InvalidOperationException("매도 가능한 수량이 부족합니다."); } ReservedQuantity += quantity; UpdatedAt = DateTime.UtcNow; } /// 매도 예약 환원 (주문 취소·거부). public void ReleaseSellReserve(int quantity) { if (quantity <= 0) { throw new ArgumentOutOfRangeException(nameof(quantity)); } if (ReservedQuantity < quantity) { throw new InvalidOperationException("환원할 예약 수량이 부족합니다."); } ReservedQuantity -= quantity; UpdatedAt = DateTime.UtcNow; } /// /// 매수 체결 반영 (배치 M2) — 평균단가법으로 AvgPrice 갱신 (순수 체결가 기준, 수수료 제외). /// AvgPrice = (AvgPrice × Quantity + fillPrice × qty) / (Quantity + qty). /// public void ApplyBuy(int qty, decimal fillPrice) { if (qty <= 0) { throw new ArgumentOutOfRangeException(nameof(qty)); } if (fillPrice <= 0) { throw new ArgumentOutOfRangeException(nameof(fillPrice)); } var newQty = Quantity + qty; AvgPrice = (AvgPrice * Quantity + fillPrice * qty) / newQty; Quantity = newQty; UpdatedAt = DateTime.UtcNow; } /// 매도 체결 반영 (배치 M2) — 예약 수량과 보유 수량을 함께 차감한다. public void ApplySell(int qty) { if (qty <= 0) { throw new ArgumentOutOfRangeException(nameof(qty)); } if (ReservedQuantity < qty || Quantity < qty) { throw new InvalidOperationException("체결할 예약/보유 수량이 부족합니다."); } Quantity -= qty; ReservedQuantity -= qty; UpdatedAt = DateTime.UtcNow; } /// /// 상장폐지 강제 청산 (배치 M2) — 예약 여부와 무관하게 보유 전량을 청산한다. /// 반환값은 청산된 수량. 예약 수량도 함께 0 으로 정리한다. /// public int ForceLiquidate() { var liquidated = Quantity; Quantity = 0; ReservedQuantity = 0; UpdatedAt = DateTime.UtcNow; return liquidated; } }