StockMasterSyncService.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. using Application.Abstractions.Data;
  2. using Application.Helpers;
  3. using Domain.Entities.Stocks;
  4. using Domain.Entities.Stocks.ValueObject;
  5. using Microsoft.EntityFrameworkCore;
  6. using Microsoft.Extensions.DependencyInjection;
  7. using Microsoft.Extensions.Logging;
  8. using Microsoft.Extensions.Options;
  9. using SharedKernel;
  10. namespace Infrastructure.StockData;
  11. /// <summary>
  12. /// 종목 마스터 동기화 — 금융위 KRX 상장종목정보 API 를 일 1회(기본 07:30 KST) 전량 수집하여 Stock upsert.
  13. /// 신규 상장 = insert, 명칭/시장 변경 = update, 스냅샷에서 사라진 종목 = 상폐 soft-off.
  14. /// 단일 엔드포인트 페이징 수집이라 조기 종료/페이지 상한 시 부분 스냅샷이 될 수 있으므로,
  15. /// 상폐 sweep 은 시장별 가드(StockMasterDelist — 0건 또는 활성 대비 80% 미만이면 보류+경고)를 통과한 시장에서만 수행.
  16. /// ServiceKey 미설정 시 로그만 남기고 skip.
  17. /// </summary>
  18. internal sealed class StockMasterSyncService(
  19. IServiceScopeFactory scopeFactory,
  20. IHttpClientFactory httpClientFactory,
  21. IOptions<AppSettings> settings,
  22. ILogger<StockMasterSyncService> logger
  23. ) : DailyScheduledService(logger)
  24. {
  25. private const string ServicePath = "/1160100/service/GetKrxListedInfoService/getItemInfo";
  26. private const int MaxPages = 50;
  27. private const int MaxBaseDateLookback = 7;
  28. protected override string JobName => "StockMasterSync";
  29. protected override TimeOnly TargetTime => ParseTime(settings.Value.StockData.MasterSyncTime, new TimeOnly(7, 30));
  30. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  31. {
  32. var cfg = settings.Value.StockData.DataGoKr;
  33. if (string.IsNullOrWhiteSpace(cfg.ServiceKey))
  34. {
  35. Logger.LogWarning("[{Job}] StockData:DataGoKr:ServiceKey 미설정 — 수집 skip", JobName);
  36. return true;
  37. }
  38. using var scope = scopeFactory.CreateScope();
  39. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  40. var client = httpClientFactory.CreateClient(DataGoKrHttp.ClientName);
  41. // 상장종목정보는 basDt 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다
  42. var baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
  43. List<DataGoKrStockParser.ListedItem>? snapshot = null;
  44. for (var back = 0; back < MaxBaseDateLookback; back++)
  45. {
  46. snapshot = await FetchSnapshotAsync(client, cfg, baseDate, ct);
  47. if (snapshot.Count > 0)
  48. {
  49. break;
  50. }
  51. baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, baseDate, ct);
  52. }
  53. if (snapshot is null || snapshot.Count == 0)
  54. {
  55. Logger.LogError("[{Job}] 상장종목 스냅샷 없음 — 최근 {Days}영업일 조회 실패", JobName, MaxBaseDateLookback);
  56. return true;
  57. }
  58. // 동일 코드 중복 행은 마지막 행 우선. 상폐 sweep 가드용으로 시장별 건수도 함께 집계 (0건 시장 포함)
  59. var byCode = new Dictionary<string, DataGoKrStockParser.ListedItem>();
  60. var snapshotCountByMarket = new Dictionary<StockMarket, int> { [StockMarket.KOSPI] = 0, [StockMarket.KOSDAQ] = 0, [StockMarket.KONEX] = 0 };
  61. foreach (var item in snapshot)
  62. {
  63. var market = MapMarket(item.MarketName);
  64. if (market is null)
  65. {
  66. continue;
  67. }
  68. if (!byCode.ContainsKey(item.Code))
  69. {
  70. snapshotCountByMarket[market.Value]++;
  71. }
  72. byCode[item.Code] = item;
  73. }
  74. var stocks = await db.Stock.ToListAsync(ct);
  75. var stockByCode = stocks.ToDictionary(c => c.Code);
  76. var inserted = 0;
  77. var updated = 0;
  78. var delisted = 0;
  79. foreach (var (code, item) in byCode)
  80. {
  81. var market = MapMarket(item.MarketName)!.Value;
  82. if (stockByCode.TryGetValue(code, out var stock))
  83. {
  84. var wasUpdated = stock.UpdatedAt;
  85. stock.UpdateMaster(item.Name, market, item.Isin);
  86. if (stock.UpdatedAt != wasUpdated)
  87. {
  88. updated++;
  89. }
  90. }
  91. else
  92. {
  93. await db.Stock.AddAsync(Stock.Create(code, item.Name, market, item.BaseDate, item.Isin), ct);
  94. inserted++;
  95. }
  96. }
  97. // 스냅샷에서 사라진 활성 종목 = 상폐 soft-off — 단, 시장별 가드(빈 응답/활성 대비 80% 미만)를 통과한 시장만
  98. var activeCountByMarket = stocks.Where(c => c.IsActive).GroupBy(c => c.Market).ToDictionary(c => c.Key, c => c.Count());
  99. var delistableMarkets = StockMasterDelist.GetDelistableMarkets(snapshotCountByMarket, activeCountByMarket).ToHashSet();
  100. foreach (var (market, snapshotCount) in snapshotCountByMarket.Where(c => !delistableMarkets.Contains(c.Key)))
  101. {
  102. Logger.LogWarning("[{Job}] {Market} 상폐 sweep 보류 — snapshot={Snapshot}, active={Active} (빈/부분 응답 방어)",
  103. JobName, market, snapshotCount, activeCountByMarket.TryGetValue(market, out var active) ? active : 0);
  104. }
  105. foreach (var stock in stocks.Where(c => c.IsActive && delistableMarkets.Contains(c.Market) && !byCode.ContainsKey(c.Code)))
  106. {
  107. stock.MarkDelisted(baseDate);
  108. delisted++;
  109. }
  110. await db.SaveChangesAsync(ct);
  111. Logger.LogInformation("[{Job}] 완료 — basDt={BaseDate}, snapshot={Snapshot}, inserted={Inserted}, updated={Updated}, delisted={Delisted}",
  112. JobName, baseDate, byCode.Count, inserted, updated, delisted);
  113. return true;
  114. }
  115. private async Task<List<DataGoKrStockParser.ListedItem>> FetchSnapshotAsync(HttpClient client, AppSettings.StockDataSection.DataGoKrSection cfg, DateOnly baseDate, CancellationToken ct)
  116. {
  117. var all = new List<DataGoKrStockParser.ListedItem>();
  118. var totalCount = int.MaxValue;
  119. for (var pageNo = 1; pageNo <= MaxPages && all.Count < totalCount; pageNo++)
  120. {
  121. var url = $"{cfg.BaseUrl.TrimEnd('/')}{ServicePath}?serviceKey={Uri.EscapeDataString(cfg.ServiceKey)}&resultType=json&numOfRows={cfg.PageSize}&pageNo={pageNo}&basDt={baseDate:yyyyMMdd}";
  122. var json = await DataGoKrHttp.GetStringWithRetryAsync(client, url, Logger, ct);
  123. var (items, total) = DataGoKrStockParser.ParseListedItems(json);
  124. totalCount = total;
  125. if (items.Count == 0)
  126. {
  127. break;
  128. }
  129. all.AddRange(items);
  130. }
  131. return all;
  132. }
  133. private static StockMarket? MapMarket(string marketName)
  134. {
  135. if (marketName.Contains("KOSPI", StringComparison.OrdinalIgnoreCase))
  136. {
  137. return StockMarket.KOSPI;
  138. }
  139. if (marketName.Contains("KOSDAQ", StringComparison.OrdinalIgnoreCase))
  140. {
  141. return StockMarket.KOSDAQ;
  142. }
  143. if (marketName.Contains("KONEX", StringComparison.OrdinalIgnoreCase))
  144. {
  145. return StockMarket.KONEX;
  146. }
  147. return null;
  148. }
  149. }