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