KrxStockMasterSyncService.cs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  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 OpenAPI) — stk_isu_base_info(KOSPI) + ksq_isu_base_info(KOSDAQ) 종목기본정보를
  12. /// 일 1회(기본 07:40 KST) 전량 수집하여 Stock upsert. 신규 상장 = insert, 명칭/시장/영문명/업종 변경 = update,
  13. /// 스냅샷에서 사라진 활성 종목 = 상폐 soft-off. ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
  14. /// 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다.
  15. /// </summary>
  16. internal sealed class KrxStockMasterSyncService(
  17. IServiceScopeFactory scopeFactory,
  18. IHttpClientFactory httpClientFactory,
  19. IOptions<AppSettings> settings,
  20. ILogger<KrxStockMasterSyncService> logger
  21. ) : DailyScheduledService(logger)
  22. {
  23. // (시장, 엔드포인트 경로) — KOSPI/KOSDAQ. KONEX(knx_*)·ETF/채권/파생 등은 D1 범위 밖.
  24. private static readonly (StockMarket Market, string Path)[] Endpoints =
  25. [
  26. (StockMarket.KOSPI, "/svc/apis/sto/stk_isu_base_info"),
  27. (StockMarket.KOSDAQ, "/svc/apis/sto/ksq_isu_base_info")
  28. ];
  29. private const int MaxBaseDateLookback = 7;
  30. protected override string JobName => "KrxStockMasterSync";
  31. protected override TimeOnly TargetTime => ParseTime(settings.Value.KRXCoKr.MasterSyncTime, new TimeOnly(7, 40));
  32. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  33. {
  34. var cfg = settings.Value.KRXCoKr;
  35. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  36. {
  37. Logger.LogWarning("[{Job}] KRXCoKr:ApiKey 미설정 — 수집 skip", JobName);
  38. return true;
  39. }
  40. using var scope = scopeFactory.CreateScope();
  41. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  42. var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
  43. // 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다
  44. var baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
  45. List<KrxStockParser.MasterItem>? snapshot = null;
  46. for (var back = 0; back < MaxBaseDateLookback; back++)
  47. {
  48. snapshot = await FetchSnapshotAsync(client, cfg, baseDate, ct);
  49. if (snapshot.Count > 0)
  50. {
  51. break;
  52. }
  53. baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, baseDate, ct);
  54. }
  55. if (snapshot is null || snapshot.Count == 0)
  56. {
  57. Logger.LogError("[{Job}] 종목기본정보 스냅샷 없음 — 최근 {Days}영업일 조회 실패", JobName, MaxBaseDateLookback);
  58. return true;
  59. }
  60. // 동일 코드 중복 행은 마지막 행 우선
  61. var byCode = new Dictionary<string, KrxStockParser.MasterItem>();
  62. foreach (var item in snapshot)
  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. if (stockByCode.TryGetValue(code, out var stock))
  74. {
  75. var wasUpdated = stock.UpdatedAt;
  76. stock.UpdateMaster(item.Name, item.Market, item.Isin, item.EnglishName, item.SectorName);
  77. if (stock.UpdatedAt != wasUpdated)
  78. {
  79. updated++;
  80. }
  81. }
  82. else
  83. {
  84. await db.Stock.AddAsync(Stock.Create(code, item.Name, item.Market, item.ListedDate, item.Isin, englishName: item.EnglishName, sectorName: item.SectorName), ct);
  85. inserted++;
  86. }
  87. }
  88. // 스냅샷에서 사라진 활성 종목 = 상폐 soft-off
  89. foreach (var stock in stocks.Where(c => c.IsActive && !byCode.ContainsKey(c.Code)))
  90. {
  91. stock.MarkDelisted(baseDate);
  92. delisted++;
  93. }
  94. await db.SaveChangesAsync(ct);
  95. Logger.LogInformation("[{Job}] 완료 — basDd={BaseDate}, snapshot={Snapshot}, inserted={Inserted}, updated={Updated}, delisted={Delisted}",
  96. JobName, baseDate, byCode.Count, inserted, updated, delisted);
  97. return true;
  98. }
  99. private async Task<List<KrxStockParser.MasterItem>> FetchSnapshotAsync(HttpClient client, AppSettings.KRXCoKrSection cfg, DateOnly baseDate, CancellationToken ct)
  100. {
  101. var all = new List<KrxStockParser.MasterItem>();
  102. foreach (var (market, path) in Endpoints)
  103. {
  104. var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={baseDate:yyyyMMdd}";
  105. var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
  106. var rows = KrxStockParser.ParseMasterInfo(json, market);
  107. Logger.LogInformation("[{Job}] {Market} basDd={BaseDate} rows={Rows}", JobName, market, baseDate, rows.Count);
  108. all.AddRange(rows);
  109. }
  110. return all;
  111. }
  112. }