Stock.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. using System.ComponentModel.DataAnnotations;
  2. using Domain.Entities.Stocks.ValueObject;
  3. namespace Domain.Entities.Stocks;
  4. /// <summary>
  5. /// 종목 마스터 (KRX 상장종목) — D1 유일 원천.
  6. /// 타 도메인(D2/D4)은 char(6) Code 를 FK 없이 denorm 보관하고 쓰기 시 AnyAsync 사전 검증한다 (d0 §2.1).
  7. /// LastTradingDate~MarketCap 은 목록/quotes 조인 회피용 denorm — 일별 시세 배치가 갱신.
  8. /// </summary>
  9. public class Stock
  10. {
  11. [Key]
  12. public int ID { get; private set; }
  13. /// <summary>단축코드 (char 6, 예: "005930") — UNIQUE</summary>
  14. public string Code { get; private set; } = default!;
  15. /// <summary>표준코드 ISIN (char 12) — 미제공 시 null</summary>
  16. public string? ISIN { get; private set; }
  17. /// <summary>종목명 (예: "삼성전자")</summary>
  18. public string Name { get; private set; } = default!;
  19. /// <summary>영문 종목명 (선택)</summary>
  20. public string? EnglishName { get; private set; }
  21. /// <summary>시장 구분 (1=KOSPI, 2=KOSDAQ, 3=KONEX)</summary>
  22. public StockMarket Market { get; private set; }
  23. /// <summary>거래 상태 (0=정상, 1=거래정지, 2=관리종목)</summary>
  24. public TradingStatus TradingStatus { get; private set; }
  25. /// <summary>DART 고유번호 (char 8) — corpCode 매핑, 미매핑 null (매핑 배치는 M2)</summary>
  26. public string? CorpCode { get; private set; }
  27. /// <summary>KRX 업종명 (선택)</summary>
  28. public string? SectorName { get; private set; }
  29. /// <summary>상장 여부 — 상폐 시 soft-off</summary>
  30. public bool IsActive { get; private set; } = true;
  31. /// <summary>상장일 — 원천 API 미제공으로 최초 수집 기준일(basDt) 근사값</summary>
  32. public DateOnly ListedDate { get; private set; }
  33. /// <summary>상장폐지일 (상폐 시)</summary>
  34. public DateOnly? DelistedDate { get; private set; }
  35. /// <summary>최근 거래일 (denorm)</summary>
  36. public DateOnly? LastTradingDate { get; private set; }
  37. /// <summary>최근 종가 (denorm)</summary>
  38. public int? LastClosePrice { get; private set; }
  39. /// <summary>최근 등락률 % (denorm)</summary>
  40. public decimal? LastChangeRate { get; private set; }
  41. /// <summary>시가총액 (denorm)</summary>
  42. public long? MarketCap { get; private set; }
  43. /// <summary>SEIBro 발행회사 고객번호 (ISSUCO_CUSTNO) — 기업정보 API 전체의 조인키. 미매핑 null (getShotnByMart 스탬핑)</summary>
  44. public int? IssucoCustno { get; private set; }
  45. /// <summary>액면가 (SEIBro getStkStatInfo PVAL) — 미보강 null</summary>
  46. public decimal? FaceValue { get; private set; }
  47. /// <summary>1주당 의결권수 (SEIBro getStkStatInfo VOTRNO) — 미보강 null</summary>
  48. public decimal? VotingRights { get; private set; }
  49. /// <summary>전자증권 여부 (SEIBro getStkStatInfo ELTSC_YN, Y=전자/N=예탁) — 미보강 null</summary>
  50. public bool? IsElectronicSecurity { get; private set; }
  51. /// <summary>총발행주식수 (SEIBro getStkStatInfo TOTAL_STK_CNT) — 미보강 null</summary>
  52. public long? TotalIssuedShares { get; private set; }
  53. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  54. public DateTime? UpdatedAt { get; private set; }
  55. private Stock() { }
  56. public static Stock Create(string code, string name, StockMarket market, DateOnly listedDate, string? isin = null, string? corpCode = null, string? englishName = null, string? sectorName = null)
  57. {
  58. if (code is not { Length: 6 } || code.Any(c => !char.IsAsciiDigit(c)))
  59. {
  60. throw new ArgumentException("code must be 6 digits", nameof(code));
  61. }
  62. if (string.IsNullOrWhiteSpace(name))
  63. {
  64. throw new ArgumentException("name required", nameof(name));
  65. }
  66. if (!Enum.IsDefined(market))
  67. {
  68. throw new ArgumentOutOfRangeException(nameof(market));
  69. }
  70. return new Stock
  71. {
  72. Code = code,
  73. Name = name.Trim(),
  74. Market = market,
  75. TradingStatus = TradingStatus.Normal,
  76. ListedDate = listedDate,
  77. ISIN = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim(),
  78. CorpCode = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim(),
  79. EnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim(),
  80. SectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim()
  81. };
  82. }
  83. /// <summary>마스터 동기화 — 이름/시장/ISIN(+영문명/업종) 반영. 상폐 상태였다면 재상장으로 복구. englishName/sectorName 은 null 전달 시 미변경.</summary>
  84. public void UpdateMaster(string name, StockMarket market, string? isin, string? englishName = null, string? sectorName = null)
  85. {
  86. var changed = false;
  87. if (!string.IsNullOrWhiteSpace(name) && Name != name.Trim())
  88. {
  89. Name = name.Trim();
  90. changed = true;
  91. }
  92. if (Enum.IsDefined(market) && Market != market)
  93. {
  94. Market = market;
  95. changed = true;
  96. }
  97. var normalizedIsin = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim();
  98. if (normalizedIsin is not null && ISIN != normalizedIsin)
  99. {
  100. ISIN = normalizedIsin;
  101. changed = true;
  102. }
  103. var normalizedEnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim();
  104. if (normalizedEnglishName is not null && EnglishName != normalizedEnglishName)
  105. {
  106. EnglishName = normalizedEnglishName;
  107. changed = true;
  108. }
  109. var normalizedSectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim();
  110. if (normalizedSectorName is not null && SectorName != normalizedSectorName)
  111. {
  112. SectorName = normalizedSectorName;
  113. changed = true;
  114. }
  115. if (!IsActive)
  116. {
  117. IsActive = true;
  118. DelistedDate = null;
  119. changed = true;
  120. }
  121. if (changed)
  122. {
  123. UpdatedAt = DateTime.UtcNow;
  124. }
  125. }
  126. /// <summary>상장폐지 — soft-off (row 는 보존, 과거 시세/참조 유지)</summary>
  127. public void MarkDelisted(DateOnly date)
  128. {
  129. if (!IsActive)
  130. {
  131. return;
  132. }
  133. IsActive = false;
  134. DelistedDate = date;
  135. UpdatedAt = DateTime.UtcNow;
  136. }
  137. public void SetTradingStatus(TradingStatus status)
  138. {
  139. if (!Enum.IsDefined(status) || TradingStatus == status)
  140. {
  141. return;
  142. }
  143. TradingStatus = status;
  144. UpdatedAt = DateTime.UtcNow;
  145. }
  146. public void SetCorpCode(string? corpCode)
  147. {
  148. var normalized = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim();
  149. if (CorpCode == normalized)
  150. {
  151. return;
  152. }
  153. CorpCode = normalized;
  154. UpdatedAt = DateTime.UtcNow;
  155. }
  156. /// <summary>SEIBro 발행회사번호 스탬핑 (getShotnByMart Code=ShortCode 조인) — null 전달·동일 값이면 무변경</summary>
  157. public void SetIssucoCustno(int? issucoCustno)
  158. {
  159. if (issucoCustno is null || IssucoCustno == issucoCustno)
  160. {
  161. return;
  162. }
  163. IssucoCustno = issucoCustno;
  164. UpdatedAt = DateTime.UtcNow;
  165. }
  166. /// <summary>SEIBro 종목 정보(getStkStatInfo) 보강 — 액면가/의결권/총발행주식수/전자증권 여부(+발행회사번호). null 전달 필드는 미변경.</summary>
  167. public void EnrichFromSeibro(int? issucoCustno, decimal? faceValue, decimal? votingRights, long? totalIssuedShares, bool? isElectronicSecurity)
  168. {
  169. var changed = false;
  170. if (issucoCustno is not null && IssucoCustno != issucoCustno)
  171. {
  172. IssucoCustno = issucoCustno;
  173. changed = true;
  174. }
  175. if (faceValue is not null && FaceValue != faceValue)
  176. {
  177. FaceValue = faceValue;
  178. changed = true;
  179. }
  180. if (votingRights is not null && VotingRights != votingRights)
  181. {
  182. VotingRights = votingRights;
  183. changed = true;
  184. }
  185. if (totalIssuedShares is not null && TotalIssuedShares != totalIssuedShares)
  186. {
  187. TotalIssuedShares = totalIssuedShares;
  188. changed = true;
  189. }
  190. if (isElectronicSecurity is not null && IsElectronicSecurity != isElectronicSecurity)
  191. {
  192. IsElectronicSecurity = isElectronicSecurity;
  193. changed = true;
  194. }
  195. if (changed)
  196. {
  197. UpdatedAt = DateTime.UtcNow;
  198. }
  199. }
  200. /// <summary>일별 시세 배치가 최근 시세 denorm 을 갱신. 과거 일자 재수집은 무시.</summary>
  201. public void UpdateLastPrice(DateOnly tradingDate, int closePrice, decimal changeRate, long? marketCap)
  202. {
  203. if (LastTradingDate.HasValue && tradingDate < LastTradingDate.Value)
  204. {
  205. return;
  206. }
  207. LastTradingDate = tradingDate;
  208. LastClosePrice = closePrice;
  209. LastChangeRate = changeRate;
  210. MarketCap = marketCap;
  211. UpdatedAt = DateTime.UtcNow;
  212. }
  213. }