PaperPosition.cs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. namespace Domain.Entities.Paper;
  4. /// <summary>
  5. /// 모의투자 보유 포지션 — (AccountID, StockCode) UNIQUE. StockCode 는 FK 없이 denorm 보관 (d0 §2.1).
  6. /// ReservedQuantity 로 이중 매도를 차단한다.
  7. /// </summary>
  8. public class PaperPosition
  9. {
  10. [ForeignKey(nameof(AccountID))]
  11. public virtual PaperAccount Account { get; private set; } = null!;
  12. [Key]
  13. public int ID { get; private set; }
  14. public int AccountID { get; private set; }
  15. /// <summary>단축코드 (char 6)</summary>
  16. public string StockCode { get; private set; } = default!;
  17. /// <summary>보유 수량</summary>
  18. public int Quantity { get; private set; }
  19. /// <summary>매도 주문으로 예약된 수량</summary>
  20. public int ReservedQuantity { get; private set; }
  21. /// <summary>평균 단가 (18,4) — 매수 체결 시 평균단가법 갱신</summary>
  22. public decimal AvgPrice { get; private set; }
  23. public DateTime UpdatedAt { get; private set; } = DateTime.UtcNow;
  24. [Timestamp]
  25. public byte[] RowVersion { get; private set; } = default!;
  26. private PaperPosition() { }
  27. public static PaperPosition Create(int accountID, string stockCode, int quantity, decimal avgPrice)
  28. {
  29. if (accountID <= 0)
  30. {
  31. throw new ArgumentOutOfRangeException(nameof(accountID));
  32. }
  33. if (stockCode is not { Length: 6 })
  34. {
  35. throw new ArgumentException("stockCode must be 6 chars", nameof(stockCode));
  36. }
  37. if (quantity < 0)
  38. {
  39. throw new ArgumentOutOfRangeException(nameof(quantity));
  40. }
  41. return new PaperPosition
  42. {
  43. AccountID = accountID,
  44. StockCode = stockCode,
  45. Quantity = quantity,
  46. ReservedQuantity = 0,
  47. AvgPrice = avgPrice
  48. };
  49. }
  50. /// <summary>매도 주문 접수 시 수량 예약 — 가용 수량(Quantity - ReservedQuantity) 부족 시 실패.</summary>
  51. public void ReserveSell(int quantity)
  52. {
  53. if (quantity <= 0)
  54. {
  55. throw new ArgumentOutOfRangeException(nameof(quantity));
  56. }
  57. if (Quantity - ReservedQuantity < quantity)
  58. {
  59. throw new InvalidOperationException("매도 가능한 수량이 부족합니다.");
  60. }
  61. ReservedQuantity += quantity;
  62. UpdatedAt = DateTime.UtcNow;
  63. }
  64. /// <summary>매도 예약 환원 (주문 취소·거부).</summary>
  65. public void ReleaseSellReserve(int quantity)
  66. {
  67. if (quantity <= 0)
  68. {
  69. throw new ArgumentOutOfRangeException(nameof(quantity));
  70. }
  71. if (ReservedQuantity < quantity)
  72. {
  73. throw new InvalidOperationException("환원할 예약 수량이 부족합니다.");
  74. }
  75. ReservedQuantity -= quantity;
  76. UpdatedAt = DateTime.UtcNow;
  77. }
  78. /// <summary>
  79. /// 매수 체결 반영 (배치 M2) — 평균단가법으로 AvgPrice 갱신 (순수 체결가 기준, 수수료 제외).
  80. /// AvgPrice = (AvgPrice × Quantity + fillPrice × qty) / (Quantity + qty).
  81. /// </summary>
  82. public void ApplyBuy(int qty, decimal fillPrice)
  83. {
  84. if (qty <= 0)
  85. {
  86. throw new ArgumentOutOfRangeException(nameof(qty));
  87. }
  88. if (fillPrice <= 0)
  89. {
  90. throw new ArgumentOutOfRangeException(nameof(fillPrice));
  91. }
  92. var newQty = Quantity + qty;
  93. AvgPrice = (AvgPrice * Quantity + fillPrice * qty) / newQty;
  94. Quantity = newQty;
  95. UpdatedAt = DateTime.UtcNow;
  96. }
  97. /// <summary>매도 체결 반영 (배치 M2) — 예약 수량과 보유 수량을 함께 차감한다.</summary>
  98. public void ApplySell(int qty)
  99. {
  100. if (qty <= 0)
  101. {
  102. throw new ArgumentOutOfRangeException(nameof(qty));
  103. }
  104. if (ReservedQuantity < qty || Quantity < qty)
  105. {
  106. throw new InvalidOperationException("체결할 예약/보유 수량이 부족합니다.");
  107. }
  108. Quantity -= qty;
  109. ReservedQuantity -= qty;
  110. UpdatedAt = DateTime.UtcNow;
  111. }
  112. /// <summary>
  113. /// 상장폐지 강제 청산 (배치 M2) — 예약 여부와 무관하게 보유 전량을 청산한다.
  114. /// 반환값은 청산된 수량. 예약 수량도 함께 0 으로 정리한다.
  115. /// </summary>
  116. public int ForceLiquidate()
  117. {
  118. var liquidated = Quantity;
  119. Quantity = 0;
  120. ReservedQuantity = 0;
  121. UpdatedAt = DateTime.UtcNow;
  122. return liquidated;
  123. }
  124. }