PostPrediction.cs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using Domain.Entities.Forum.Posts.ValueObject;
  4. using Domain.Entities.Members;
  5. namespace Domain.Entities.Forum.Posts;
  6. /// <summary>
  7. /// 예측글 (d2 §③) — 종목 방향/기간 예측을 작성 시점에 확정 기록하고, 채점 배치(M4)가 상태를 확정한다.
  8. /// 작성 시 BasePrice(작성 시점 최신 T+1 종가)와 DueDate(영업일 + HorizonDays)를 스냅샷으로 고정한다.
  9. /// 예측글이 삭제(IsDeleted)돼도 Prediction 은 유지·채점된다(삭제 회피 어뷰징 차단).
  10. /// Stock FK 는 걸지 않는다(D1 소유). PostID 는 1:1(UQ).
  11. /// M2 는 <b>Pending 생성만</b> — SettledPrice/SettledAt/상태 확정은 M4 PredictionSettlementService 소관.
  12. /// </summary>
  13. public class PostPrediction
  14. {
  15. [ForeignKey(nameof(PostID))]
  16. public virtual Post Post { get; private set; } = null!;
  17. [ForeignKey(nameof(MemberID))]
  18. public virtual Member Member { get; private set; } = null!;
  19. [Key]
  20. public int ID { get; private set; }
  21. /// <summary>예측이 속한 게시글 (1:1)</summary>
  22. public int PostID { get; private set; }
  23. /// <summary>작성 회원</summary>
  24. public int MemberID { get; private set; }
  25. /// <summary>예측 종목 단축코드 (char 6)</summary>
  26. public string StockCode { get; private set; } = default!;
  27. /// <summary>예측 방향 (1=상승, 2=하락)</summary>
  28. public PredictionDirection Direction { get; private set; }
  29. /// <summary>작성 시점 기준가 (최신 T+1 종가 스냅샷)</summary>
  30. public decimal BasePrice { get; private set; }
  31. /// <summary>목표가 (선택) — 지정 시 도달 여부로 채점</summary>
  32. public decimal? TargetPrice { get; private set; }
  33. /// <summary>예측 기간 (영업일 수: 5 또는 20)</summary>
  34. public short HorizonDays { get; private set; }
  35. /// <summary>채점 예정일 (작성일 + HorizonDays 영업일)</summary>
  36. public DateOnly DueDate { get; private set; }
  37. /// <summary>상태 (0=Pending/1=Hit/2=Miss/3=Void)</summary>
  38. public PredictionStatus Status { get; private set; } = PredictionStatus.Pending;
  39. /// <summary>채점 시점 종가 (M4 확정)</summary>
  40. public decimal? SettledPrice { get; private set; }
  41. /// <summary>채점 일시 (M4 확정)</summary>
  42. public DateTime? SettledAt { get; private set; }
  43. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  44. private PostPrediction() { }
  45. /// <summary>Pending 예측 생성 (d2 §③ M2). 채점은 M4.</summary>
  46. public static PostPrediction Create(
  47. int postID,
  48. int memberID,
  49. string stockCode,
  50. PredictionDirection direction,
  51. decimal basePrice,
  52. short horizonDays,
  53. DateOnly dueDate,
  54. decimal? targetPrice = null)
  55. {
  56. if (postID <= 0)
  57. {
  58. throw new ArgumentOutOfRangeException(nameof(postID));
  59. }
  60. if (memberID <= 0)
  61. {
  62. throw new ArgumentOutOfRangeException(nameof(memberID));
  63. }
  64. if (stockCode is not { Length: 6 } || stockCode.Any(c => !char.IsAsciiDigit(c)))
  65. {
  66. throw new ArgumentException("stockCode must be 6 digits", nameof(stockCode));
  67. }
  68. if (!Enum.IsDefined(direction))
  69. {
  70. throw new ArgumentOutOfRangeException(nameof(direction));
  71. }
  72. if (basePrice <= 0)
  73. {
  74. throw new ArgumentOutOfRangeException(nameof(basePrice));
  75. }
  76. if (horizonDays <= 0)
  77. {
  78. throw new ArgumentOutOfRangeException(nameof(horizonDays));
  79. }
  80. return new PostPrediction
  81. {
  82. PostID = postID,
  83. MemberID = memberID,
  84. StockCode = stockCode,
  85. Direction = direction,
  86. BasePrice = basePrice,
  87. TargetPrice = targetPrice is > 0 ? targetPrice : null,
  88. HorizonDays = horizonDays,
  89. DueDate = dueDate,
  90. Status = PredictionStatus.Pending
  91. };
  92. }
  93. }