using System.ComponentModel.DataAnnotations; using Domain.Entities.Stocks.ValueObject; namespace Domain.Entities.Stocks; /// /// 종목 마스터 (KRX 상장종목) — D1 유일 원천. /// 타 도메인(D2/D4)은 char(6) Code 를 FK 없이 denorm 보관하고 쓰기 시 AnyAsync 사전 검증한다 (d0 §2.1). /// LastTradingDate~MarketCap 은 목록/quotes 조인 회피용 denorm — 일별 시세 배치가 갱신. /// public class Stock { [Key] public int ID { get; private set; } /// 단축코드 (char 6, 예: "005930") — UNIQUE public string Code { get; private set; } = default!; /// 표준코드 ISIN (char 12) — 미제공 시 null public string? ISIN { get; private set; } /// 종목명 (예: "삼성전자") public string Name { get; private set; } = default!; /// 영문 종목명 (선택) public string? EnglishName { get; private set; } /// 시장 구분 (1=KOSPI, 2=KOSDAQ, 3=KONEX) public StockMarket Market { get; private set; } /// 거래 상태 (0=정상, 1=거래정지, 2=관리종목) public TradingStatus TradingStatus { get; private set; } /// DART 고유번호 (char 8) — corpCode 매핑, 미매핑 null (매핑 배치는 M2) public string? CorpCode { get; private set; } /// KRX 업종명 (선택) public string? SectorName { get; private set; } /// 상장 여부 — 상폐 시 soft-off public bool IsActive { get; private set; } = true; /// 상장일 — 원천 API 미제공으로 최초 수집 기준일(basDt) 근사값 public DateOnly ListedDate { get; private set; } /// 상장폐지일 (상폐 시) public DateOnly? DelistedDate { get; private set; } /// 최근 거래일 (denorm) public DateOnly? LastTradingDate { get; private set; } /// 최근 종가 (denorm) public int? LastClosePrice { get; private set; } /// 최근 등락률 % (denorm) public decimal? LastChangeRate { get; private set; } /// 시가총액 (denorm) public long? MarketCap { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; private set; } private Stock() { } public static Stock Create(string code, string name, StockMarket market, DateOnly listedDate, string? isin = null, string? corpCode = null, string? englishName = null, string? sectorName = null) { if (code is not { Length: 6 } || code.Any(c => !char.IsAsciiDigit(c))) { throw new ArgumentException("code must be 6 digits", nameof(code)); } if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("name required", nameof(name)); } if (!Enum.IsDefined(market)) { throw new ArgumentOutOfRangeException(nameof(market)); } return new Stock { Code = code, Name = name.Trim(), Market = market, TradingStatus = TradingStatus.Normal, ListedDate = listedDate, ISIN = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim(), CorpCode = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim(), EnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim(), SectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim() }; } /// 마스터 동기화 — 이름/시장/ISIN(+영문명/업종) 반영. 상폐 상태였다면 재상장으로 복구. englishName/sectorName 은 null 전달 시 미변경. public void UpdateMaster(string name, StockMarket market, string? isin, string? englishName = null, string? sectorName = null) { var changed = false; if (!string.IsNullOrWhiteSpace(name) && Name != name.Trim()) { Name = name.Trim(); changed = true; } if (Enum.IsDefined(market) && Market != market) { Market = market; changed = true; } var normalizedIsin = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim(); if (normalizedIsin is not null && ISIN != normalizedIsin) { ISIN = normalizedIsin; changed = true; } var normalizedEnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim(); if (normalizedEnglishName is not null && EnglishName != normalizedEnglishName) { EnglishName = normalizedEnglishName; changed = true; } var normalizedSectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim(); if (normalizedSectorName is not null && SectorName != normalizedSectorName) { SectorName = normalizedSectorName; changed = true; } if (!IsActive) { IsActive = true; DelistedDate = null; changed = true; } if (changed) { UpdatedAt = DateTime.UtcNow; } } /// 상장폐지 — soft-off (row 는 보존, 과거 시세/참조 유지) public void MarkDelisted(DateOnly date) { if (!IsActive) { return; } IsActive = false; DelistedDate = date; UpdatedAt = DateTime.UtcNow; } public void SetTradingStatus(TradingStatus status) { if (!Enum.IsDefined(status) || TradingStatus == status) { return; } TradingStatus = status; UpdatedAt = DateTime.UtcNow; } public void SetCorpCode(string? corpCode) { var normalized = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim(); if (CorpCode == normalized) { return; } CorpCode = normalized; UpdatedAt = DateTime.UtcNow; } /// 일별 시세 배치가 최근 시세 denorm 을 갱신. 과거 일자 재수집은 무시. public void UpdateLastPrice(DateOnly tradingDate, int closePrice, decimal changeRate, long? marketCap) { if (LastTradingDate.HasValue && tradingDate < LastTradingDate.Value) { return; } LastTradingDate = tradingDate; LastClosePrice = closePrice; LastChangeRate = changeRate; MarketCap = marketCap; UpdatedAt = DateTime.UtcNow; } }