SeibroIssuerSyncService.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. using Application.Abstractions.Data;
  2. using Application.Helpers;
  3. using Microsoft.EntityFrameworkCore;
  4. using Microsoft.Extensions.DependencyInjection;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. using SharedKernel;
  8. namespace Infrastructure.StockData;
  9. /// <summary>
  10. /// SEIBro 발행회사번호(ISSUCO_CUSTNO) 조인키 부트스트랩 + 종목 보강 (Wave 0) — 기본 06:30 KST, `Seibro:IssuerSync` 게이트.
  11. /// a) getShotnByMart × MART_TPCD {11,12,13,14} (주식 예산 4콜) → SeibroIssuerCode upsert(UQ Market+ShortCode)
  12. /// → Stock.IssucoCustno 스탬핑 (Code=단축코드 조인, 이미 같은 값이면 무변경)
  13. /// b) getStkStatInfo per-키 rolling — IssucoCustno 는 있는데 FaceValue 가 null 인 활성 Stock 우선(미보강 우선),
  14. /// SeibroRollingSweep + SeibroQuota(Stock 카테고리) 상한 내 SHOTN_ISIN 단건 호출 → Stock.EnrichFromSeibro.
  15. /// resultCount=0 은 정상 빈 결과(해당 종목만 미보강 유지 — 다음 실행에서 재시도), 종목별 SaveChanges 로 중단 시에도 진행분 보존.
  16. /// 실패 처리: HTTP·파싱 오류는 경고 로그 + 해당 run 중단(false 반환) → DailyScheduledService 가 RetryDelay 후 재시도.
  17. /// quota 소진은 정상 종료(true) — 일일 예산은 KST 자정 롤오버로만 회복되므로 재시도가 무의미하다.
  18. /// ApiKey 미설정 시 로그만 남기고 skip (다른 수집 배치와 동일 정책).
  19. /// </summary>
  20. internal sealed class SeibroIssuerSyncService(
  21. IServiceScopeFactory scopeFactory,
  22. IHttpClientFactory httpClientFactory,
  23. SeibroQuota quota,
  24. IOptions<AppSettings> settings,
  25. ILogger<SeibroIssuerSyncService> logger
  26. ) : DailyScheduledService(logger)
  27. {
  28. // 시장구분(MART_TPCD) — [11]유가 [12]코스닥 [13]K-OTC [14]코넥스. K-OTC 는 KRX Stock 미커버 시장의 유일 마스터.
  29. private static readonly string[] Markets = ["11", "12", "13", "14"];
  30. protected override string JobName => "SeibroIssuerSync";
  31. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.IssuerSyncTime, new TimeOnly(6, 30));
  32. protected override int MaxRetryCount => 2;
  33. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  34. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  35. {
  36. var cfg = settings.Value.Seibro;
  37. using var scope = scopeFactory.CreateScope();
  38. var collectorSettings = scope.ServiceProvider.GetRequiredService<ICollectorSettingsProvider>();
  39. if (!await collectorSettings.IsEnabledAsync(CollectorFlag.SeibroIssuer, ct))
  40. {
  41. return true;
  42. }
  43. cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Seibro, ct) ?? cfg.ApiKey };
  44. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  45. {
  46. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  47. return true;
  48. }
  49. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  50. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  51. try
  52. {
  53. if (!await SyncIssuerCodesAsync(db, client, cfg, ct))
  54. {
  55. return true; // quota 소진 — 오늘은 더 진행 불가 (정상 종료)
  56. }
  57. await EnrichStocksAsync(db, client, cfg, ct);
  58. return true;
  59. }
  60. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  61. {
  62. Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
  63. return false;
  64. }
  65. }
  66. /// <summary>a) 시장별 단축코드 스냅샷 4콜 → SeibroIssuerCode upsert + Stock.IssucoCustno 스탬핑. quota 소진 시 false.</summary>
  67. private async Task<bool> SyncIssuerCodesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  68. {
  69. var custnoByShortCode = new Dictionary<string, int>();
  70. var first = true;
  71. foreach (var market in Markets)
  72. {
  73. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  74. {
  75. Logger.LogWarning("[{Job}] 주식 카테고리 일일 예산({Budget}) 소진 — getShotnByMart {Market} 부터 중단", JobName, cfg.StockBudget, market);
  76. return false;
  77. }
  78. if (!first && cfg.DelayMs > 0)
  79. {
  80. await Task.Delay(cfg.DelayMs, ct);
  81. }
  82. first = false;
  83. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getShotnByMart", [new("MART_TPCD", market)], Logger, ct);
  84. var envelope = SeibroXml.Parse(xml);
  85. var rows = SeibroShotnByMartParser.Parse(envelope);
  86. var (inserted, updated) = await SeibroIssuerImport.UpsertIssuerCodesAsync(db, market, rows, ct);
  87. Logger.LogInformation("[{Job}] getShotnByMart MART_TPCD={Market} rows={Rows} (result={Result}) — inserted={Inserted}, updated={Updated}",
  88. JobName, market, rows.Count, envelope.ResultCount, inserted, updated);
  89. foreach (var row in rows)
  90. {
  91. custnoByShortCode[row.ShortCode] = row.IssucoCustno;
  92. }
  93. }
  94. var stamped = await SeibroIssuerImport.StampStockIssucoCustnoAsync(db, custnoByShortCode, ct);
  95. Logger.LogInformation("[{Job}] Stock.IssucoCustno 스탬핑 — {Stamped}건 (스냅샷 {Snapshot}코드)", JobName, stamped, custnoByShortCode.Count);
  96. return true;
  97. }
  98. // getStkStatInfo rolling 마커 job 키 (리뷰 결함 #1 — FaceValue null 판정은 정상 0행 종목을 매 실행 재조회)
  99. private const string EnrichJobKey = "stock-enrich";
  100. /// <summary>
  101. /// b) getStkStatInfo rolling — 미보강(FaceValue null) 활성 종목부터 quota 잔여분 내에서 보강.
  102. /// 대상 정렬은 "FaceValue null" 이 아닌 SeibroFetchMarker(조회 시도 시각) 기준 — 정상 0행 종목이 매 실행 최우선 재조회되어
  103. /// 나머지가 굶던 결함(#1) 해소. 조회 시도(0행 포함)마다 마커 Touch → 다음 실행에서 후순위로 밀려 나머지가 진행된다.
  104. /// </summary>
  105. private async Task EnrichStocksAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  106. {
  107. var targets = await db.Stock.Where(c => c.IsActive && c.IssucoCustno != null && c.FaceValue == null).OrderBy(c => c.ID).ToListAsync(ct);
  108. if (targets.Count == 0)
  109. {
  110. Logger.LogInformation("[{Job}] getStkStatInfo 보강 대상 없음", JobName);
  111. return;
  112. }
  113. var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, EnrichJobKey, ct);
  114. var ordered = targets.Select(c => (Key: c, LastUpdatedAt: markers.TryGetValue(c.Code, out var last) ? (DateTime?)last : null)).ToList();
  115. var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  116. var enriched = 0;
  117. var processed = await SeibroRollingSweep.RunAsync(
  118. targets: ordered,
  119. fetchAndUpsert: async (stock, token) => {
  120. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  121. {
  122. return; // 예산 경합 방어 — maxPerRun 산정 이후 다른 배치가 소비한 경우 skip
  123. }
  124. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkStatInfo", [new("SHOTN_ISIN", stock.Code)], Logger, token);
  125. var rows = SeibroStkStatParser.Parse(SeibroXml.Parse(xml));
  126. if (rows.Count == 0)
  127. {
  128. // 0행이어도 조회 시도 마커를 남겨 다음 실행에서 후순위로 밀린다 (starvation 방지)
  129. await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token);
  130. Logger.LogInformation("[{Job}] getStkStatInfo SHOTN_ISIN={Code} 0행 — 미보강 유지, 마커 갱신 (다음 실행 후순위)", JobName, stock.Code);
  131. return;
  132. }
  133. var row = rows[0];
  134. stock.EnrichFromSeibro(row.IssucoCustno, row.FaceValue, row.VotingRights, row.TotalIssuedShares, row.IsElectronicSecurity);
  135. await db.SaveChangesAsync(token);
  136. await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token);
  137. enriched++;
  138. },
  139. maxPerRun: maxPerRun,
  140. delayMs: cfg.DelayMs,
  141. ct: ct);
  142. Logger.LogInformation("[{Job}] getStkStatInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max}), 보강={Enriched}",
  143. JobName, targets.Count, processed, maxPerRun, enriched);
  144. }
  145. }