StockMasterSyncService.cs 5.8 KB

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