SeibroDividendSyncService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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 배당·권리 수집 (Wave 1 ★P0) — 기본 06:40 KST(IssuerSync 06:30 다음 슬롯), `Seibro:DividendSync` 게이트.
  11. /// 세 단계가 하나의 기업(Corp) 카테고리 예산(SeibroQuota, Seibro:CorpBudget)을 공유한다.
  12. ///
  13. /// a) DividendSchedule 날짜 스윕: getDivSchedulInfo(BEGIN_STD_DT=day, EXPRY 생략 → 그날만, custno 미지정 → 전체 회사).
  14. /// 3년 창을 최신일→과거로 훑으며 미적재일만 수집한다.
  15. /// ⚠️ 날짜 스윕에 KrxBackfill(영업일·휴장 스킵)을 쓰지 않는다 — 배당 권리기준일 상당수가 분기말/연말(0331·0630·0930·1231)이며
  16. /// 이 날짜가 토·일(예: 20161231 토, 20180331 토, 20180630 토)인 경우가 실제 샘플에 존재한다. 영업일만 훑으면 이런 기준일을 통째로 놓친다.
  17. /// → 주말·휴장 포함 전 캘린더일을 훑는다(3년 ≈ 1,095콜, Corp 예산 40,000 내 충분).
  18. /// b) Dividend rolling: 대상 = DividendSchedule 에 등장한 DISTINCT IssucoCustno 중 Dividend 미수집(우선)·stale 순.
  19. /// getDivInfo(ISSUCO_CUSTNO, BEGIN_STD_DT=3년전, EXPRY_STD_DT=오늘) 1콜 → 여러 배당 반환 → Dividend upsert(Isin+RgtStdDt).
  20. /// getDivInfo 응답엔 ISSUCO_CUSTNO 가 없으므로(샘플 확인) Isin 으로만 upsert 한다.
  21. /// c) RightsBaseDate rolling: 대상 = Stock.IssucoCustno non-null 중 RightsBaseDate 미수집(우선)·stale 순.
  22. /// getStddtInfo(ISSUCO_CUSTNO, BEGIN_STD_DT=3년전, EXPRY_STD_DT=오늘) → RightsBaseDate upsert.
  23. ///
  24. /// 실패/0행 = 정상 빈결과(다음 실행 재시도). HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도).
  25. /// quota 소진 = 정상 종료(true) — 일일 예산은 KST 자정 롤오버로만 회복. ApiKey 미설정 시 로그만 남기고 skip.
  26. /// </summary>
  27. internal sealed class SeibroDividendSyncService(
  28. IServiceScopeFactory scopeFactory,
  29. IHttpClientFactory httpClientFactory,
  30. SeibroQuota quota,
  31. IOptions<AppSettings> settings,
  32. ILogger<SeibroDividendSyncService> logger
  33. ) : DailyScheduledService(logger)
  34. {
  35. // 마커 job 키 (리뷰 결함 #1) — 정상 0행/조용한 날 재조회·starvation 해소
  36. private const string ScheduleJobKey = "div-schedule"; // date-sweep (SweptDate)
  37. private const string DivInfoJobKey = "div-info"; // rolling (FetchMarker)
  38. private const string RightsJobKey = "rights-basedate"; // rolling (FetchMarker)
  39. protected override string JobName => "SeibroDividendSync";
  40. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.DividendSyncTime, new TimeOnly(6, 40));
  41. protected override int MaxRetryCount => 2;
  42. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  43. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  44. {
  45. var cfg = settings.Value.Seibro;
  46. using var scope = scopeFactory.CreateScope();
  47. var collectorSettings = scope.ServiceProvider.GetRequiredService<ICollectorSettingsProvider>();
  48. if (!await collectorSettings.IsEnabledAsync(CollectorFlag.SeibroDividend, ct))
  49. {
  50. return true;
  51. }
  52. cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Seibro, ct) ?? cfg.ApiKey };
  53. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  54. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  55. {
  56. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  57. return true;
  58. }
  59. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  60. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  61. var startDate = todayKst.AddYears(-years);
  62. try
  63. {
  64. if (!await SweepSchedulesAsync(db, client, cfg, startDate, todayKst, ct))
  65. {
  66. return true; // quota 소진 — 오늘은 더 진행 불가 (정상 종료)
  67. }
  68. if (!await RollDividendsAsync(db, client, cfg, startDate, todayKst, ct))
  69. {
  70. return true;
  71. }
  72. await RollRightsBaseDatesAsync(db, client, cfg, startDate, todayKst, ct);
  73. return true;
  74. }
  75. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  76. {
  77. Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
  78. return false;
  79. }
  80. }
  81. /// <summary>a) getDivSchedulInfo 날짜 스윕 (전 캘린더일, 최신일→과거) — 미적재일만 수집. quota 소진 시 false.</summary>
  82. private async Task<bool> SweepSchedulesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  83. {
  84. var fetched = 0;
  85. var first = true;
  86. for (var day = today; day >= startDate; day = day.AddDays(-1))
  87. {
  88. ct.ThrowIfCancellationRequested();
  89. // 데이터 존재 OR 조회완료 마커 존재 → 스킵 (0행 배당일도 마커로 재조회 회피, 리뷰 결함 #1)
  90. if (await db.DividendSchedule.AsNoTracking().AnyAsync(c => c.RgtStdDt == day, ct) || await SeibroMarkers.IsSweptAsync(db, ScheduleJobKey, day, null, ct))
  91. {
  92. continue;
  93. }
  94. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  95. {
  96. Logger.LogWarning("[{Job}] 기업 카테고리 일일 예산({Budget}) 소진 — getDivSchedulInfo {Day} 부터 중단", JobName, cfg.CorpBudget, day);
  97. return false;
  98. }
  99. if (!first && cfg.DelayMs > 0)
  100. {
  101. await Task.Delay(cfg.DelayMs, ct);
  102. }
  103. first = false;
  104. try
  105. {
  106. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDivSchedulInfo", [new("BEGIN_STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  107. var rows = SeibroDivSchedulParser.Parse(SeibroXml.Parse(xml));
  108. var (inserted, updated) = await SeibroDividendImport.UpsertSchedulesAsync(db, rows, ct);
  109. await SeibroMarkers.MarkSweptAsync(db, ScheduleJobKey, day, null, ct); // 0행 포함 조회완료 기록
  110. Logger.LogInformation("[{Job}] getDivSchedulInfo {Day} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, rows.Count, inserted, updated);
  111. fetched++;
  112. }
  113. catch (Exception ex) when (ex is not OperationCanceledException)
  114. {
  115. // soft-error 응답 등 단일 날짜 실패는 미마킹 후 skip → 다음 실행 재시도 (run 전체 중단 방지)
  116. Logger.LogWarning(ex, "[{Job}] getDivSchedulInfo {Day} 실패 — skip(다음 실행 재시도)", JobName, day);
  117. }
  118. }
  119. Logger.LogInformation("[{Job}] 배당일정 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched);
  120. return true;
  121. }
  122. /// <summary>b) getDivInfo rolling — DividendSchedule 에 등장한 회사번호 중 미수집·stale 우선. quota 잔여분 내. 예산 소진 시 false.</summary>
  123. private async Task<bool> RollDividendsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  124. {
  125. var scheduleCustnos = await db.DividendSchedule.AsNoTracking().Select(c => c.IssucoCustno).Distinct().ToListAsync(ct);
  126. if (scheduleCustnos.Count == 0)
  127. {
  128. Logger.LogInformation("[{Job}] getDivInfo 대상 없음 (DividendSchedule 비어 있음)", JobName);
  129. return true;
  130. }
  131. // 회사번호 → 조회 시도(0행 포함) 마커 시각 (미조회면 null → 최우선). 무배당 회사도 Dividend 행이 안 남아 매 실행 재조회되던 결함(#1) 해소.
  132. var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, DivInfoJobKey, ct);
  133. var targets = scheduleCustnos.Select(custno => (Key: custno, LastUpdatedAt: markers.TryGetValue(custno.ToString(), out var last) ? (DateTime?)last : null)).ToList();
  134. var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget);
  135. var beginStr = startDate.ToString("yyyyMMdd");
  136. var expiryStr = today.ToString("yyyyMMdd");
  137. var quotaExhausted = false;
  138. var processed = await SeibroRollingSweep.RunAsync(
  139. targets: targets,
  140. fetchAndUpsert: async (custno, token) => {
  141. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  142. {
  143. quotaExhausted = true;
  144. return;
  145. }
  146. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDivInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("BEGIN_STD_DT", beginStr), new("EXPRY_STD_DT", expiryStr)], Logger, token);
  147. var rows = SeibroDivInfoParser.Parse(SeibroXml.Parse(xml));
  148. var (inserted, updated) = await SeibroDividendImport.UpsertDividendsAsync(db, rows, token);
  149. await SeibroMarkers.TouchFetchMarkerAsync(db, DivInfoJobKey, custno.ToString(), token); // 0행(무배당) 포함 조회시각 스탬핑
  150. Logger.LogInformation("[{Job}] getDivInfo ISSUCO_CUSTNO={Custno} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, rows.Count, inserted, updated);
  151. },
  152. maxPerRun: maxPerRun,
  153. delayMs: cfg.DelayMs,
  154. ct: ct);
  155. Logger.LogInformation("[{Job}] getDivInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  156. return !quotaExhausted;
  157. }
  158. /// <summary>c) getStddtInfo rolling — Stock.IssucoCustno non-null 중 미수집·stale 우선. quota 잔여분 내.</summary>
  159. private async Task RollRightsBaseDatesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  160. {
  161. var custnos = await db.Stock.AsNoTracking().Where(c => c.IssucoCustno != null).Select(c => c.IssucoCustno!.Value).Distinct().ToListAsync(ct);
  162. if (custnos.Count == 0)
  163. {
  164. Logger.LogInformation("[{Job}] getStddtInfo 대상 없음 (Stock.IssucoCustno 미스탬핑)", JobName);
  165. return;
  166. }
  167. // 조회 시도(0행 포함) 마커 기준 정렬 — 권리 없는 회사도 재조회 회피(#1)
  168. var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, RightsJobKey, ct);
  169. var targets = custnos.Select(custno => (Key: custno, LastUpdatedAt: markers.TryGetValue(custno.ToString(), out var last) ? (DateTime?)last : null)).ToList();
  170. var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget);
  171. var beginStr = startDate.ToString("yyyyMMdd");
  172. var expiryStr = today.ToString("yyyyMMdd");
  173. var processed = await SeibroRollingSweep.RunAsync(
  174. targets: targets,
  175. fetchAndUpsert: async (custno, token) => {
  176. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  177. {
  178. return;
  179. }
  180. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStddtInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("BEGIN_STD_DT", beginStr), new("EXPRY_STD_DT", expiryStr)], Logger, token);
  181. var rows = SeibroStddtParser.Parse(SeibroXml.Parse(xml));
  182. var (inserted, updated) = await SeibroDividendImport.UpsertRightsBaseDatesAsync(db, rows, token);
  183. await SeibroMarkers.TouchFetchMarkerAsync(db, RightsJobKey, custno.ToString(), token);
  184. Logger.LogInformation("[{Job}] getStddtInfo ISSUCO_CUSTNO={Custno} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, rows.Count, inserted, updated);
  185. },
  186. maxPerRun: maxPerRun,
  187. delayMs: cfg.DelayMs,
  188. ct: ct);
  189. Logger.LogInformation("[{Job}] getStddtInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  190. }
  191. }