SeibroCorpActionSyncService.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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 3) — 기본 07:20 KST(SupplySync 07:00 다음 슬롯), `Seibro:CorpActionSync` 게이트.
  11. /// 총회·상호변경·대금·단주 4단계는 기업(Corp) 예산(Seibro:CorpBudget), CB/BW 행사 2단계는 주식(Stock) 예산(Seibro:StockBudget)을 공유한다.
  12. ///
  13. /// a) 총회 이벤트 구동: RightsBaseDate(Wave 1 적재)의 RGT_RACD∈{001,002,003,009} 인 (회사번호, 기준일) →
  14. /// getGmeetInfo + getGmeetMeasureInfo(Corp). rolling(미수집 (회사,기준일) 우선).
  15. /// b) 상호변경 이벤트 구동: RightsBaseDate RGT_RACD=204 인 (회사번호, 기준일) → getFmnmAltInfo(Corp) rolling.
  16. /// c) PaymentSchedule: getCostPaySchedul 날짜 스윕(TH1_PAY_TERM_BEGIN_DT) × PAY_COST_TPCD{1,3}(Corp) — 지급일+구분 window delete+insert.
  17. /// d) OddLotPayment: PaymentSchedule 중 PayCostTpcd=3(단주) 인 (회사번호, 지급일) → getOddLotInfo(Corp) rolling.
  18. /// e) ConvertibleExercise: getXrcStkOptionXrcInfo 날짜 스윕(RGT_STD_DT=권리행사일)(Stock) — 권리행사일 window delete+insert.
  19. /// 반환된 BOND_ISIN 이 (f)의 발견 소스.
  20. /// f) ConvertibleBondTarget: (e)에서 발견된 DISTINCT BondIsin → getXrcStkStatInfo(BOND_ISIN)(Stock) rolling.
  21. /// (Wave 4 BondIssuance 는 아직 없으니 (e) 결과만 시드 — 발견 소스 단일.)
  22. ///
  23. /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip.
  24. /// ⚠️ getXrcStk* 실샘플이 result="0" 이라 CB/BW 필드 매핑은 스펙 기준 — 운영 첫 수집 시 로그로 확인.
  25. /// </summary>
  26. internal sealed class SeibroCorpActionSyncService(
  27. IServiceScopeFactory scopeFactory,
  28. IHttpClientFactory httpClientFactory,
  29. SeibroQuota quota,
  30. IOptions<AppSettings> settings,
  31. ILogger<SeibroCorpActionSyncService> logger
  32. ) : DailyScheduledService(logger)
  33. {
  34. // 총회계열 권리사유코드 (RGT_RACD) — getGmeetInfo/getGmeetMeasureInfo 트리거
  35. private static readonly string[] MeetingRacds = ["001", "002", "003", "009"];
  36. // 상호변경 권리사유코드 (RGT_RACD) — getFmnmAltInfo 트리거
  37. private const string NameChangeRacd = "204";
  38. // 대금지급구분 (PAY_COST_TPCD) — 1배당금 3단주대금
  39. private static readonly string[] PayCostTpcds = ["1", "3"];
  40. private const string OddLotPayCostTpcd = "3";
  41. protected override string JobName => "SeibroCorpActionSync";
  42. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.CorpActionSyncTime, new TimeOnly(7, 20));
  43. protected override int MaxRetryCount => 2;
  44. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  45. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  46. {
  47. var cfg = settings.Value.Seibro;
  48. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  49. {
  50. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  51. return true;
  52. }
  53. using var scope = scopeFactory.CreateScope();
  54. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  55. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  56. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  57. var startDate = todayKst.AddYears(-years);
  58. try
  59. {
  60. // a) 총회 이벤트 구동 (Corp)
  61. if (!await RollMeetingsAsync(db, client, cfg, ct))
  62. {
  63. return true;
  64. }
  65. // b) 상호변경 이벤트 구동 (Corp)
  66. if (!await RollNameChangesAsync(db, client, cfg, ct))
  67. {
  68. return true;
  69. }
  70. // c) 대금지급일정 날짜 스윕 × 구분{1,3} (Corp)
  71. if (!await SweepPaymentSchedulesAsync(db, client, cfg, startDate, todayKst, ct))
  72. {
  73. return true;
  74. }
  75. // d) 단주대금 이벤트 구동 (Corp)
  76. if (!await RollOddLotsAsync(db, client, cfg, ct))
  77. {
  78. return true;
  79. }
  80. // e) CB/BW 행사 날짜 스윕 (Stock) — (f)의 발견 소스
  81. if (!await SweepExercisesAsync(db, client, cfg, startDate, todayKst, ct))
  82. {
  83. return true;
  84. }
  85. // f) CB/BW 행사대상 rolling per-채권ISIN (Stock)
  86. await RollBondTargetsAsync(db, client, cfg, ct);
  87. return true;
  88. }
  89. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  90. {
  91. Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
  92. return false;
  93. }
  94. }
  95. /// <summary>
  96. /// a) 총회 이벤트 구동 — RightsBaseDate RGT_RACD∈{001,002,003,009} 의 (회사번호, 기준일) 중 GeneralMeeting 미수집·stale 우선.
  97. /// (회사, 기준일) 당 getGmeetInfo + getGmeetMeasureInfo 2콜(각각 Corp 예산 소비). 예산 소진 시 false.
  98. /// </summary>
  99. private async Task<bool> RollMeetingsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  100. {
  101. var triggers = await db.RightsBaseDate.AsNoTracking()
  102. .Where(c => MeetingRacds.Contains(c.RgtRacd))
  103. .Select(c => new { c.IssucoCustno, c.RgtStdDt })
  104. .Distinct()
  105. .ToListAsync(ct);
  106. if (triggers.Count == 0)
  107. {
  108. Logger.LogInformation("[{Job}] 총회 트리거 없음 (RightsBaseDate 총회계열 미적재)", JobName);
  109. return true;
  110. }
  111. var lastByKey = await db.GeneralMeeting.AsNoTracking()
  112. .GroupBy(c => new { c.IssucoCustno, c.RgtStdDt })
  113. .Select(g => new { g.Key.IssucoCustno, g.Key.RgtStdDt, Last = g.Max(c => c.UpdatedAt) })
  114. .ToListAsync(ct);
  115. var lastLookup = lastByKey.ToDictionary(c => (c.IssucoCustno, c.RgtStdDt), c => c.Last);
  116. var targets = triggers.Select(t => (
  117. Key: (t.IssucoCustno, t.RgtStdDt),
  118. LastUpdatedAt: lastLookup.TryGetValue((t.IssucoCustno, t.RgtStdDt), out var last) ? (DateTime?)last : null
  119. )).ToList();
  120. // getGmeetInfo + getGmeetMeasureInfo = 2콜/트리거 → maxPerRun 은 잔여예산/2 로 산정(각 콜이 개별 TryConsume).
  121. var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget) / 2;
  122. var quotaExhausted = false;
  123. var processed = await SeibroRollingSweep.RunAsync(
  124. targets: targets,
  125. fetchAndUpsert: async (key, token) => {
  126. var (custno, stdDt) = key;
  127. var stdDtStr = stdDt.ToString("yyyyMMdd");
  128. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  129. {
  130. quotaExhausted = true;
  131. return;
  132. }
  133. var gmeetXml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getGmeetInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDtStr)], Logger, token);
  134. var gmeetRows = SeibroGmeetParser.Parse(SeibroXml.Parse(gmeetXml));
  135. var (gi, gu) = await SeibroCorpActionImport.UpsertGeneralMeetingsAsync(db, gmeetRows, token);
  136. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  137. {
  138. quotaExhausted = true;
  139. return;
  140. }
  141. if (cfg.DelayMs > 0)
  142. {
  143. await Task.Delay(cfg.DelayMs, token);
  144. }
  145. var measureXml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getGmeetMeasureInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDtStr)], Logger, token);
  146. var measureRows = SeibroGmeetMeasureParser.Parse(SeibroXml.Parse(measureXml));
  147. var (mi, mu) = await SeibroCorpActionImport.UpsertMeetingAgendasAsync(db, measureRows, token);
  148. Logger.LogInformation("[{Job}] 총회 ISSUCO_CUSTNO={Custno} RGT_STD_DT={Std} — 총회 ins={Gi}/upd={Gu}, 안건 ins={Mi}/upd={Mu}", JobName, custno, stdDtStr, gi, gu, mi, mu);
  149. },
  150. maxPerRun: maxPerRun,
  151. delayMs: cfg.DelayMs,
  152. ct: ct);
  153. Logger.LogInformation("[{Job}] 총회 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  154. return !quotaExhausted;
  155. }
  156. /// <summary>
  157. /// b) 상호변경 이벤트 구동 — RightsBaseDate RGT_RACD=204 의 (회사번호, 기준일) 중 CorpNameChange 미수집·stale 우선.
  158. /// getFmnmAltInfo(ISSUCO_CUSTNO, RGT_STD_DT) 1콜(Corp). 예산 소진 시 false.
  159. /// </summary>
  160. private async Task<bool> RollNameChangesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  161. {
  162. var triggers = await db.RightsBaseDate.AsNoTracking()
  163. .Where(c => c.RgtRacd == NameChangeRacd)
  164. .Select(c => new { c.IssucoCustno, c.RgtStdDt })
  165. .Distinct()
  166. .ToListAsync(ct);
  167. if (triggers.Count == 0)
  168. {
  169. Logger.LogInformation("[{Job}] 상호변경 트리거 없음 (RightsBaseDate RGT_RACD=204 미적재)", JobName);
  170. return true;
  171. }
  172. var lastByKey = await db.CorpNameChange.AsNoTracking()
  173. .GroupBy(c => new { c.IssucoCustno, c.RgtStdDt })
  174. .Select(g => new { g.Key.IssucoCustno, g.Key.RgtStdDt, Last = g.Max(c => c.UpdatedAt) })
  175. .ToListAsync(ct);
  176. var lastLookup = lastByKey.ToDictionary(c => (c.IssucoCustno, c.RgtStdDt), c => c.Last);
  177. var targets = triggers.Select(t => (
  178. Key: (t.IssucoCustno, t.RgtStdDt),
  179. LastUpdatedAt: lastLookup.TryGetValue((t.IssucoCustno, t.RgtStdDt), out var last) ? (DateTime?)last : null
  180. )).ToList();
  181. var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget);
  182. var quotaExhausted = false;
  183. var processed = await SeibroRollingSweep.RunAsync(
  184. targets: targets,
  185. fetchAndUpsert: async (key, token) => {
  186. var (custno, stdDt) = key;
  187. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  188. {
  189. quotaExhausted = true;
  190. return;
  191. }
  192. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getFmnmAltInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("RGT_STD_DT", stdDt.ToString("yyyyMMdd"))], Logger, token);
  193. var rows = SeibroFmnmAltParser.Parse(SeibroXml.Parse(xml));
  194. var (inserted, updated) = await SeibroCorpActionImport.UpsertNameChangesAsync(db, rows, token);
  195. Logger.LogInformation("[{Job}] 상호변경 ISSUCO_CUSTNO={Custno} RGT_STD_DT={Std} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, stdDt, rows.Count, inserted, updated);
  196. },
  197. maxPerRun: maxPerRun,
  198. delayMs: cfg.DelayMs,
  199. ct: ct);
  200. Logger.LogInformation("[{Job}] 상호변경 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  201. return !quotaExhausted;
  202. }
  203. /// <summary>
  204. /// c) 대금지급일정 날짜 스윕(전 캘린더일, 최신→과거) × 구분{1,3} — 지급일+구분 window delete+insert. 미적재 (일,구분)만.
  205. /// ⚠️ 대금지급일은 배당·단주 지급일이라 주말/휴장 가능성 있어(SeibroDividendSync 판단 동일) 전 캘린더일 스윕. 예산 소진 시 false.
  206. /// </summary>
  207. private async Task<bool> SweepPaymentSchedulesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  208. {
  209. var fetched = 0;
  210. var first = true;
  211. for (var day = today; day >= startDate; day = day.AddDays(-1))
  212. {
  213. foreach (var tpcd in PayCostTpcds)
  214. {
  215. ct.ThrowIfCancellationRequested();
  216. if (await db.PaymentSchedule.AsNoTracking().AnyAsync(c => c.Th1PayTermBeginDt == day && c.PayCostTpcd == tpcd, ct))
  217. {
  218. continue; // 이미 적재된 (지급일, 구분) — resumable
  219. }
  220. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  221. {
  222. Logger.LogWarning("[{Job}] 기업 예산({Budget}) 소진 — getCostPaySchedul {Day}/{Tpcd} 부터 중단", JobName, cfg.CorpBudget, day, tpcd);
  223. return false;
  224. }
  225. if (!first && cfg.DelayMs > 0)
  226. {
  227. await Task.Delay(cfg.DelayMs, ct);
  228. }
  229. first = false;
  230. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getCostPaySchedul", [new("TH1_PAY_TERM_BEGIN_DT", day.ToString("yyyyMMdd")), new("PAY_COST_TPCD", tpcd)], Logger, ct);
  231. var rows = SeibroCostPaySchedulParser.Parse(SeibroXml.Parse(xml));
  232. var (inserted, deleted) = await SeibroCorpActionImport.ReplacePaymentScheduleWindowAsync(db, day, tpcd, rows, ct);
  233. Logger.LogInformation("[{Job}] getCostPaySchedul {Day}/{Tpcd} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, tpcd, rows.Count, inserted, deleted);
  234. fetched++;
  235. }
  236. }
  237. Logger.LogInformation("[{Job}] 대금지급일정 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  238. return true;
  239. }
  240. /// <summary>
  241. /// d) 단주대금 이벤트 구동 — PaymentSchedule 중 PAY_COST_TPCD=3(단주) 의 (회사번호, 지급일) 중 OddLotPayment 미수집·stale 우선.
  242. /// getOddLotInfo(ISSUCO_CUSTNO, TH1_PAY_TERM_BEGIN_DT) 1콜(Corp). 예산 소진 시 false.
  243. /// </summary>
  244. private async Task<bool> RollOddLotsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  245. {
  246. var triggers = await db.PaymentSchedule.AsNoTracking()
  247. .Where(c => c.PayCostTpcd == OddLotPayCostTpcd)
  248. .Select(c => new { c.IssucoCustno, c.Th1PayTermBeginDt })
  249. .Distinct()
  250. .ToListAsync(ct);
  251. if (triggers.Count == 0)
  252. {
  253. Logger.LogInformation("[{Job}] 단주대금 트리거 없음 (PaymentSchedule 단주(구분3) 미적재)", JobName);
  254. return true;
  255. }
  256. var lastByKey = await db.OddLotPayment.AsNoTracking()
  257. .GroupBy(c => new { c.IssucoCustno, c.PayDate })
  258. .Select(g => new { g.Key.IssucoCustno, g.Key.PayDate, Last = g.Max(c => c.UpdatedAt) })
  259. .ToListAsync(ct);
  260. var lastLookup = lastByKey.ToDictionary(c => (c.IssucoCustno, c.PayDate), c => c.Last);
  261. var targets = triggers.Select(t => (
  262. Key: (t.IssucoCustno, PayDate: t.Th1PayTermBeginDt),
  263. LastUpdatedAt: lastLookup.TryGetValue((t.IssucoCustno, t.Th1PayTermBeginDt), out var last) ? (DateTime?)last : null
  264. )).ToList();
  265. var maxPerRun = quota.Remaining(SeibroCategory.Corp, cfg.CorpBudget);
  266. var quotaExhausted = false;
  267. var processed = await SeibroRollingSweep.RunAsync(
  268. targets: targets,
  269. fetchAndUpsert: async (key, token) => {
  270. var (custno, payDate) = key;
  271. if (!quota.TryConsume(SeibroCategory.Corp, 1, cfg.CorpBudget))
  272. {
  273. quotaExhausted = true;
  274. return;
  275. }
  276. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getOddLotInfo", [new("ISSUCO_CUSTNO", custno.ToString()), new("TH1_PAY_TERM_BEGIN_DT", payDate.ToString("yyyyMMdd"))], Logger, token);
  277. var rows = SeibroOddLotParser.Parse(SeibroXml.Parse(xml));
  278. var (inserted, updated) = await SeibroCorpActionImport.UpsertOddLotsAsync(db, custno, payDate, rows, token);
  279. Logger.LogInformation("[{Job}] 단주대금 ISSUCO_CUSTNO={Custno} 지급일={Pay} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, custno, payDate, rows.Count, inserted, updated);
  280. },
  281. maxPerRun: maxPerRun,
  282. delayMs: cfg.DelayMs,
  283. ct: ct);
  284. Logger.LogInformation("[{Job}] 단주대금 rolling 완료 — 트리거={Triggers}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  285. return !quotaExhausted;
  286. }
  287. /// <summary>
  288. /// e) CB/BW 행사 날짜 스윕(전 캘린더일, 최신→과거) — 권리행사일 window delete+insert. 미적재일만. Stock 예산. 예산 소진 시 false.
  289. /// ⚠️ 권리행사일(RGT_STD_DT)은 영업일 성격이나 스펙상 확정 불가라 전 캘린더일 스윕(보수적). 반환 BOND_ISIN 이 (f) 발견 소스.
  290. /// </summary>
  291. private async Task<bool> SweepExercisesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  292. {
  293. var fetched = 0;
  294. var first = true;
  295. for (var day = today; day >= startDate; day = day.AddDays(-1))
  296. {
  297. ct.ThrowIfCancellationRequested();
  298. if (await db.ConvertibleExercise.AsNoTracking().AnyAsync(c => c.RgtStdDt == day, ct))
  299. {
  300. continue; // 이미 적재된 권리행사일 — resumable
  301. }
  302. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  303. {
  304. Logger.LogWarning("[{Job}] 주식 예산({Budget}) 소진 — getXrcStkOptionXrcInfo {Day} 부터 중단", JobName, cfg.StockBudget, day);
  305. return false;
  306. }
  307. if (!first && cfg.DelayMs > 0)
  308. {
  309. await Task.Delay(cfg.DelayMs, ct);
  310. }
  311. first = false;
  312. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getXrcStkOptionXrcInfo", [new("RGT_STD_DT", day.ToString("yyyyMMdd"))], Logger, ct);
  313. var rows = SeibroXrcStkOptionXrcParser.Parse(SeibroXml.Parse(xml));
  314. var (inserted, deleted) = await SeibroCorpActionImport.ReplaceExerciseWindowAsync(db, day, day, rows, ct);
  315. Logger.LogInformation("[{Job}] getXrcStkOptionXrcInfo {Day} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, rows.Count, inserted, deleted);
  316. fetched++;
  317. }
  318. Logger.LogInformation("[{Job}] CB/BW 행사 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}일", JobName, startDate, today, fetched);
  319. return true;
  320. }
  321. /// <summary>
  322. /// f) CB/BW 행사대상 rolling per-채권ISIN — (e) ConvertibleExercise 에서 발견된 DISTINCT BondIsin 중 ConvertibleBondTarget 미수집·stale 우선.
  323. /// getXrcStkStatInfo(BOND_ISIN) 1콜(Stock). Wave 4 BondIssuance 미도입이라 발견 소스는 (e) 결과뿐.
  324. /// </summary>
  325. private async Task RollBondTargetsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  326. {
  327. var bondIsins = await db.ConvertibleExercise.AsNoTracking().Select(c => c.BondIsin).Distinct().ToListAsync(ct);
  328. if (bondIsins.Count == 0)
  329. {
  330. Logger.LogInformation("[{Job}] getXrcStkStatInfo 대상 없음 (ConvertibleExercise 비어 있음 — 발견 소스 없음)", JobName);
  331. return;
  332. }
  333. var lastByBond = await db.ConvertibleBondTarget.AsNoTracking().GroupBy(c => c.BondIsin).Select(g => new { BondIsin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.BondIsin, c => c.Last, ct);
  334. var targets = bondIsins.Select(isin => (Key: isin, LastUpdatedAt: lastByBond.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList();
  335. var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
  336. var processed = await SeibroRollingSweep.RunAsync(
  337. targets: targets,
  338. fetchAndUpsert: async (bondIsin, token) => {
  339. if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
  340. {
  341. return;
  342. }
  343. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getXrcStkStatInfo", [new("BOND_ISIN", bondIsin)], Logger, token);
  344. var rows = SeibroXrcStkStatParser.Parse(SeibroXml.Parse(xml));
  345. var (inserted, updated) = await SeibroCorpActionImport.UpsertBondTargetsAsync(db, rows, token);
  346. Logger.LogInformation("[{Job}] getXrcStkStatInfo BOND_ISIN={Bond} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, bondIsin, rows.Count, inserted, updated);
  347. },
  348. maxPerRun: maxPerRun,
  349. delayMs: cfg.DelayMs,
  350. ct: ct);
  351. Logger.LogInformation("[{Job}] getXrcStkStatInfo rolling 완료 — 대상채권={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  352. }
  353. }