SeibroDerivSyncService.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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 파생결합증권 ELS/DLS 수집 (Wave 5) — 기본 08:00 KST(BondSync 07:40 다음 슬롯), `Seibro:DerivSync` 게이트.
  11. /// 전 단계가 파생(Deriv) 예산(Seibro:DerivBudget)을 공유한다.
  12. ///
  13. /// a) DerivIssuance: getDerivCombiIssuInfo 날짜 스윕(ISSU_DT) × 종류 SECN_KACD{4101,4102,4301,4302} 3년 → 발견(다수 종목/일).
  14. /// b) DerivMaster rolling: 대상 = DerivIssuance.Isin 미수집·stale → getDerivCombiIsinInfo(ISIN) → PK Isin upsert.
  15. /// c) DerivAsset / DerivAssetExercise / DerivRedemptionCondition rolling: DerivMaster 있는 ISIN → getAssetInfo/getAssetXrcInfo/getRedCondiInfo(ISIN) → upsert.
  16. /// d) DerivRedemption: getRedIsinInfo 날짜 스윕(RED_DT) × 종류{4101,4102,4301,4302} 3년 → 상환일×종류 window delete+insert.
  17. /// e) ElsUnredeemedScale: getBassetUnredScale 일별 13기초자산(STND_BASSET_CD, KSD02* 13종) × SECN_TPCD{41,43} = 최대 26콜/일 3년 스윕(KrxBackfill 영업일).
  18. ///
  19. /// 실패/0행 = 정상 빈결과. HTTP·파싱 오류 = 경고 + false(RetryDelay 후 재시도). quota 소진 = 정상 종료(true). ApiKey 미설정 시 skip.
  20. /// ⚠️ getDerivCombiIssuInfo 요청 파라미터명 미해결 — 가이드에 SECN_TPCD:41 vs SECN_KACD:4101 혼재. 스펙 기준(SECN_KACD 종류코드)으로 구현했으나
  21. /// **라이브 1콜로 파라미터명 확정 필요**(4101/4102/4301/4302). getRedIsinInfo 는 SECN_KACD(4101 등), getBassetUnredScale 는 SECN_TPCD(41/43 2자리) — API별 문서대로.
  22. /// ⚠️ 기초자산코드 2체계: getBassetUnredScale 입력 STND_BASSET_CD=KSD02*(13종) vs getAssetInfo 반환 KSD31* — 매핑 흡수 없이 원문 저장(엔티티 주석 참조).
  23. /// ⚠️ 파생결합 유니버스 큼(일 수십~수백 발행) → rolling 최근발행 우선 + resumable(여러 날 자동 완성).
  24. /// </summary>
  25. internal sealed class SeibroDerivSyncService(
  26. IServiceScopeFactory scopeFactory,
  27. IHttpClientFactory httpClientFactory,
  28. SeibroQuota quota,
  29. IOptions<AppSettings> settings,
  30. ILogger<SeibroDerivSyncService> logger
  31. ) : DailyScheduledService(logger)
  32. {
  33. // 종목종류코드 (SECN_KACD) — 4101ELS 4102ELB 4301DLS 4302DLB. getDerivCombiIssuInfo·getRedIsinInfo 요청 파라미터.
  34. // ⚠️ getDerivCombiIssuInfo 는 파라미터명이 SECN_TPCD vs SECN_KACD 미해결 — 스펙(종류코드 4101…) 기준, 라이브 1콜로 확정 필요.
  35. private static readonly string[] DerivSecnKacds = ["4101", "4102", "4301", "4302"];
  36. // 미상환규모 종목구분 (SECN_TPCD) — 41ELS 43DLS. getBassetUnredScale 요청 파라미터(2자리).
  37. private static readonly string[] UnredSecnTpcds = ["41", "43"];
  38. // 주요 기초자산코드 (STND_BASSET_CD, 13종) — getBassetUnredScale 입력 KSD02* 체계 (파생결합증권.md §헤더 확정).
  39. private static readonly string[] Basset13 =
  40. [
  41. "KSD090000101", // Brent
  42. "KSD090000201", // WTI
  43. "KSD100000101", // 금
  44. "KSD100000201", // 은
  45. "KSD010000101", // KOSPI200
  46. "KSD020000101", // EURO STOXX 50
  47. "KSD020000201", // HSCEI(항셍H)
  48. "KSD020000301", // S&P500
  49. "KSD020000401", // FTSE100
  50. "KSD020000501", // NIKKEI225
  51. "KSD020000601", // DOW JONES
  52. "KSD020000901", // DAX
  53. "KSD020001901" // HSI
  54. ];
  55. protected override string JobName => "SeibroDerivSync";
  56. protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.DerivSyncTime, new TimeOnly(8, 0));
  57. protected override int MaxRetryCount => 2;
  58. protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
  59. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  60. {
  61. var cfg = settings.Value.Seibro;
  62. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  63. {
  64. Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
  65. return true;
  66. }
  67. using var scope = scopeFactory.CreateScope();
  68. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  69. var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
  70. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 3;
  71. var startDate = todayKst.AddYears(-years);
  72. try
  73. {
  74. // a) 파생결합 발행 날짜 스윕 × 종류 (Deriv) — DerivMaster 발견 소스
  75. if (!await SweepIssuancesAsync(db, client, cfg, startDate, todayKst, ct))
  76. {
  77. return true;
  78. }
  79. // b) DerivMaster rolling per-ISIN (Deriv) — DerivIssuance 미수집·stale 우선
  80. if (!await RollMastersAsync(db, client, cfg, ct))
  81. {
  82. return true;
  83. }
  84. // c) 기초자산 / 기초자산행사 / 상환조건 rolling per-ISIN (Deriv) — DerivMaster 있는 것
  85. if (!await RollAssetsAsync(db, client, cfg, ct))
  86. {
  87. return true;
  88. }
  89. if (!await RollAssetExercisesAsync(db, client, cfg, ct))
  90. {
  91. return true;
  92. }
  93. if (!await RollRedemptionConditionsAsync(db, client, cfg, ct))
  94. {
  95. return true;
  96. }
  97. // d) 상환종목 날짜 스윕 × 종류 (Deriv)
  98. if (!await SweepRedemptionsAsync(db, client, cfg, startDate, todayKst, ct))
  99. {
  100. return true;
  101. }
  102. // e) 기초자산별 미상환규모 일별 13×2 스윕 (Deriv, 영업일)
  103. await SweepUnredeemedScaleAsync(db, client, cfg, startDate, todayKst, ct);
  104. return true;
  105. }
  106. catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
  107. {
  108. Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
  109. return false;
  110. }
  111. }
  112. /// <summary>
  113. /// a) 파생결합 발행 날짜 스윕(ISSU_DT, 최신→과거) × 종류 SECN_KACD{4101,4102,4301,4302} — 미적재 (발행일,종류)만.
  114. /// 발행일당 다수 종목 upsert(UQ Isin, SECN_KACD·ISSU_DT 스탬핑). 예산 소진 시 false. 발견분이 (b) 마스터 rolling 대상.
  115. /// ⚠️ 발행일은 영업일 성격이나 스펙 확정 불가라 전 캘린더일 스윕(보수적, 기존 SEIBro 배치 판단과 동일).
  116. /// ⚠️ SECN_KACD 파라미터명 미해결 — 라이브 1콜로 확정 필요(가이드 SECN_TPCD:41 vs SECN_KACD:4101 혼재).
  117. /// </summary>
  118. private async Task<bool> SweepIssuancesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  119. {
  120. var fetched = 0;
  121. var first = true;
  122. for (var day = today; day >= startDate; day = day.AddDays(-1))
  123. {
  124. foreach (var kacd in DerivSecnKacds)
  125. {
  126. ct.ThrowIfCancellationRequested();
  127. if (await db.DerivIssuance.AsNoTracking().AnyAsync(c => c.IssuDt == day && c.SecnKacd == kacd, ct))
  128. {
  129. continue; // 이미 적재된 (발행일, 종류) — resumable
  130. }
  131. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  132. {
  133. Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getDerivCombiIssuInfo {Day}/{Kacd} 부터 중단", JobName, cfg.DerivBudget, day, kacd);
  134. return false;
  135. }
  136. if (!first && cfg.DelayMs > 0)
  137. {
  138. await Task.Delay(cfg.DelayMs, ct);
  139. }
  140. first = false;
  141. // SECN_KACD 파라미터명은 라이브 1콜로 확정 필요 (스펙 기준 종류코드 사용)
  142. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDerivCombiIssuInfo", [new("ISSU_DT", day.ToString("yyyyMMdd")), new("SECN_KACD", kacd)], Logger, ct);
  143. var rows = SeibroDerivCombiIssuParser.Parse(SeibroXml.Parse(xml));
  144. var (inserted, updated) = await SeibroDerivImport.UpsertDerivIssuancesAsync(db, kacd, day, rows, ct);
  145. Logger.LogInformation("[{Job}] getDerivCombiIssuInfo {Day}/{Kacd} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, day, kacd, rows.Count, inserted, updated);
  146. fetched++;
  147. }
  148. }
  149. Logger.LogInformation("[{Job}] 파생 발행 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  150. return true;
  151. }
  152. /// <summary>
  153. /// b) DerivMaster rolling — DerivIssuance.Isin 중 DerivMaster 미수집·stale 우선.
  154. /// getDerivCombiIsinInfo(ISIN) 1콜(Deriv) → PK Isin upsert(ISIN 요청값 스탬핑). 예산 소진 시 false.
  155. /// </summary>
  156. private async Task<bool> RollMastersAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  157. {
  158. var issuedIsins = await db.DerivIssuance.AsNoTracking().Select(c => c.Isin).Distinct().ToListAsync(ct);
  159. if (issuedIsins.Count == 0)
  160. {
  161. Logger.LogInformation("[{Job}] getDerivCombiIsinInfo 대상 없음 (DerivIssuance 비어 있음)", JobName);
  162. return true;
  163. }
  164. var lastByIsin = await db.DerivMaster.AsNoTracking().Select(c => new { c.Isin, c.UpdatedAt }).ToDictionaryAsync(c => c.Isin, c => c.UpdatedAt, ct);
  165. var targets = issuedIsins.Select(isin => (Key: isin, LastUpdatedAt: lastByIsin.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList();
  166. var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget);
  167. var quotaExhausted = false;
  168. var processed = await SeibroRollingSweep.RunAsync(
  169. targets: targets,
  170. fetchAndUpsert: async (isin, token) => {
  171. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  172. {
  173. quotaExhausted = true;
  174. return;
  175. }
  176. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getDerivCombiIsinInfo", [new("ISIN", isin)], Logger, token);
  177. var rows = SeibroDerivCombiIsinParser.Parse(SeibroXml.Parse(xml));
  178. var ok = await SeibroDerivImport.UpsertDerivMasterAsync(db, isin, rows, token);
  179. Logger.LogInformation("[{Job}] getDerivCombiIsinInfo ISIN={Isin} rows={Rows} — upsert={Ok}", JobName, isin, rows.Count, ok);
  180. },
  181. maxPerRun: maxPerRun,
  182. delayMs: cfg.DelayMs,
  183. ct: ct);
  184. Logger.LogInformation("[{Job}] 파생 마스터 rolling 완료 — 발견={Issued}, 처리={Processed} (maxPerRun={Max})", JobName, issuedIsins.Count, processed, maxPerRun);
  185. return !quotaExhausted;
  186. }
  187. /// <summary>
  188. /// c-1) 기초자산 rolling — DerivMaster 있는 ISIN 중 DerivAsset 미수집·stale 우선.
  189. /// getAssetInfo(ISIN) 1콜(Deriv) → UQ(Isin,AssetSeq) upsert(ISIN 요청값 스탬핑, StndBassetCd 원문). 예산 소진 시 false.
  190. /// stale 판정: 해당 ISIN 의 DerivAsset 최신 UpdatedAt(없으면 미수집 → 최우선).
  191. /// </summary>
  192. private async Task<bool> RollAssetsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  193. {
  194. var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct);
  195. if (masterIsins.Count == 0)
  196. {
  197. Logger.LogInformation("[{Job}] getAssetInfo 대상 없음 (DerivMaster 비어 있음)", JobName);
  198. return true;
  199. }
  200. var lastByIsin = await db.DerivAsset.AsNoTracking().GroupBy(c => c.Isin).Select(g => new { Isin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Isin, c => c.Last, ct);
  201. var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: lastByIsin.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList();
  202. var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget);
  203. var quotaExhausted = false;
  204. var processed = await SeibroRollingSweep.RunAsync(
  205. targets: targets,
  206. fetchAndUpsert: async (isin, token) => {
  207. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  208. {
  209. quotaExhausted = true;
  210. return;
  211. }
  212. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getAssetInfo", [new("ISIN", isin)], Logger, token);
  213. var rows = SeibroAssetParser.Parse(SeibroXml.Parse(xml));
  214. var (inserted, updated) = await SeibroDerivImport.UpsertDerivAssetsAsync(db, isin, rows, token);
  215. Logger.LogInformation("[{Job}] getAssetInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated);
  216. },
  217. maxPerRun: maxPerRun,
  218. delayMs: cfg.DelayMs,
  219. ct: ct);
  220. Logger.LogInformation("[{Job}] 기초자산 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  221. return !quotaExhausted;
  222. }
  223. /// <summary>
  224. /// c-2) 기초자산 행사정보 rolling — DerivMaster 있는 ISIN 중 DerivAssetExercise 미수집·stale 우선.
  225. /// getAssetXrcInfo(ISIN) 1콜(Deriv) → UQ(Isin,AssetSeq,XrcStdSeq) upsert(ISIN 요청값 스탬핑). 예산 소진 시 false.
  226. /// </summary>
  227. private async Task<bool> RollAssetExercisesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  228. {
  229. var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct);
  230. if (masterIsins.Count == 0)
  231. {
  232. return true;
  233. }
  234. var lastByIsin = await db.DerivAssetExercise.AsNoTracking().GroupBy(c => c.Isin).Select(g => new { Isin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Isin, c => c.Last, ct);
  235. var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: lastByIsin.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList();
  236. var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget);
  237. var quotaExhausted = false;
  238. var processed = await SeibroRollingSweep.RunAsync(
  239. targets: targets,
  240. fetchAndUpsert: async (isin, token) => {
  241. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  242. {
  243. quotaExhausted = true;
  244. return;
  245. }
  246. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getAssetXrcInfo", [new("ISIN", isin)], Logger, token);
  247. var rows = SeibroAssetXrcParser.Parse(SeibroXml.Parse(xml));
  248. var (inserted, updated) = await SeibroDerivImport.UpsertDerivAssetExercisesAsync(db, isin, rows, token);
  249. Logger.LogInformation("[{Job}] getAssetXrcInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated);
  250. },
  251. maxPerRun: maxPerRun,
  252. delayMs: cfg.DelayMs,
  253. ct: ct);
  254. Logger.LogInformation("[{Job}] 기초자산 행사 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  255. return !quotaExhausted;
  256. }
  257. /// <summary>
  258. /// c-3) 상환조건 rolling — DerivMaster 있는 ISIN 중 DerivRedemptionCondition 미수집·stale 우선.
  259. /// getRedCondiInfo(ISIN) 1콜(Deriv) → UQ(Isin,RedCondiTpcd,ValatNtimesSeq) upsert(ISIN 요청값 스탬핑, 1000자 텍스트). 예산 소진 시 false.
  260. /// </summary>
  261. private async Task<bool> RollRedemptionConditionsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
  262. {
  263. var masterIsins = await db.DerivMaster.AsNoTracking().Select(c => c.Isin).ToListAsync(ct);
  264. if (masterIsins.Count == 0)
  265. {
  266. return true;
  267. }
  268. var lastByIsin = await db.DerivRedemptionCondition.AsNoTracking().GroupBy(c => c.Isin).Select(g => new { Isin = g.Key, Last = g.Max(c => c.UpdatedAt) }).ToDictionaryAsync(c => c.Isin, c => c.Last, ct);
  269. var targets = masterIsins.Select(isin => (Key: isin, LastUpdatedAt: lastByIsin.TryGetValue(isin, out var last) ? (DateTime?)last : null)).ToList();
  270. var maxPerRun = quota.Remaining(SeibroCategory.Deriv, cfg.DerivBudget);
  271. var quotaExhausted = false;
  272. var processed = await SeibroRollingSweep.RunAsync(
  273. targets: targets,
  274. fetchAndUpsert: async (isin, token) => {
  275. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  276. {
  277. quotaExhausted = true;
  278. return;
  279. }
  280. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getRedCondiInfo", [new("ISIN", isin)], Logger, token);
  281. var rows = SeibroRedCondiParser.Parse(SeibroXml.Parse(xml));
  282. var (inserted, updated) = await SeibroDerivImport.UpsertDerivRedemptionConditionsAsync(db, isin, rows, token);
  283. Logger.LogInformation("[{Job}] getRedCondiInfo ISIN={Isin} rows={Rows} — inserted={Inserted}, updated={Updated}", JobName, isin, rows.Count, inserted, updated);
  284. },
  285. maxPerRun: maxPerRun,
  286. delayMs: cfg.DelayMs,
  287. ct: ct);
  288. Logger.LogInformation("[{Job}] 상환조건 rolling 완료 — 대상={Targets}, 처리={Processed} (maxPerRun={Max})", JobName, targets.Count, processed, maxPerRun);
  289. return !quotaExhausted;
  290. }
  291. /// <summary>
  292. /// d) 상환종목 날짜 스윕(RED_DT, 최신→과거) × 종류 SECN_KACD{4101,4102,4301,4302} — 미적재 (상환일,종류)만.
  293. /// 상환일×종류 window delete+insert(UQ Isin+RedDt+DerisecExerTpcd, RED_DT·SECN_KACD 스탬핑). 예산 소진 시 false.
  294. /// ⚠️ 상환일은 영업일 성격이나 스펙 확정 불가라 전 캘린더일 스윕(보수적). resumable: 해당 (상환일,종류) 스탬핑 존재 여부.
  295. /// </summary>
  296. private async Task<bool> SweepRedemptionsAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  297. {
  298. var fetched = 0;
  299. var first = true;
  300. for (var day = today; day >= startDate; day = day.AddDays(-1))
  301. {
  302. foreach (var kacd in DerivSecnKacds)
  303. {
  304. ct.ThrowIfCancellationRequested();
  305. if (await db.DerivRedemption.AsNoTracking().AnyAsync(c => c.RedDt == day && c.SecnKacd == kacd, ct))
  306. {
  307. continue; // 이미 적재된 (상환일, 종류) — resumable
  308. }
  309. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  310. {
  311. Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getRedIsinInfo {Day}/{Kacd} 부터 중단", JobName, cfg.DerivBudget, day, kacd);
  312. return false;
  313. }
  314. if (!first && cfg.DelayMs > 0)
  315. {
  316. await Task.Delay(cfg.DelayMs, ct);
  317. }
  318. first = false;
  319. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getRedIsinInfo", [new("RED_DT", day.ToString("yyyyMMdd")), new("SECN_KACD", kacd)], Logger, ct);
  320. var rows = SeibroRedIsinParser.Parse(SeibroXml.Parse(xml));
  321. var (inserted, deleted) = await SeibroDerivImport.ReplaceRedemptionWindowAsync(db, kacd, day, day, rows, ct);
  322. Logger.LogInformation("[{Job}] getRedIsinInfo {Day}/{Kacd} rows={Rows} — inserted={Inserted}, deleted={Deleted}", JobName, day, kacd, rows.Count, inserted, deleted);
  323. fetched++;
  324. }
  325. }
  326. Logger.LogInformation("[{Job}] 상환종목 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  327. return true;
  328. }
  329. /// <summary>
  330. /// e) 기초자산별 미상환규모 스윕 — 영업일(KrxBackfill) × 13기초자산(KSD02*) × SECN_TPCD{41,43} = 최대 26콜/일.
  331. /// 미상환규모는 영업일 기준(장중 지표)이라 KrxBackfill.EnumerateBusinessDaysDescending 재사용, resumable(existsForDate 스킵).
  332. /// UQ(StdDt,StndBassetCd,SecnTpcd) upsert(3종 요청값 스탬핑). 예산 소진 시 즉시 종료.
  333. /// </summary>
  334. private async Task SweepUnredeemedScaleAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, DateOnly startDate, DateOnly today, CancellationToken ct)
  335. {
  336. var holidays = await db.MarketHoliday.AsNoTracking().Select(c => c.Date).ToListAsync(ct);
  337. var holidaySet = holidays.ToHashSet();
  338. var fetched = 0;
  339. var first = true;
  340. for (var day = today; day >= startDate; day = day.AddDays(-1))
  341. {
  342. if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || holidaySet.Contains(day))
  343. {
  344. continue; // 주말·휴장일 스킵 (미상환규모는 영업일 지표)
  345. }
  346. if (await db.ElsUnredeemedScale.AsNoTracking().AnyAsync(c => c.StdDt == day, ct))
  347. {
  348. continue; // 이미 적재된 기준일 — resumable
  349. }
  350. foreach (var basset in Basset13)
  351. {
  352. foreach (var tpcd in UnredSecnTpcds)
  353. {
  354. ct.ThrowIfCancellationRequested();
  355. if (!quota.TryConsume(SeibroCategory.Deriv, 1, cfg.DerivBudget))
  356. {
  357. Logger.LogWarning("[{Job}] 파생 예산({Budget}) 소진 — getBassetUnredScale {Day} 부터 중단", JobName, cfg.DerivBudget, day);
  358. Logger.LogInformation("[{Job}] 미상환규모 스윕 부분완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  359. return;
  360. }
  361. if (!first && cfg.DelayMs > 0)
  362. {
  363. await Task.Delay(cfg.DelayMs, ct);
  364. }
  365. first = false;
  366. var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getBassetUnredScale", [new("STD_DT", day.ToString("yyyyMMdd")), new("STND_BASSET_CD", basset), new("SECN_TPCD", tpcd)], Logger, ct);
  367. var rows = SeibroBassetUnredScaleParser.Parse(SeibroXml.Parse(xml));
  368. var ok = await SeibroDerivImport.UpsertUnredeemedScaleAsync(db, day, basset, tpcd, rows, ct);
  369. Logger.LogInformation("[{Job}] getBassetUnredScale {Day}/{Basset}/{Tpcd} rows={Rows} — upsert={Ok}", JobName, day, basset, tpcd, rows.Count, ok);
  370. fetched++;
  371. }
  372. }
  373. }
  374. Logger.LogInformation("[{Job}] 미상환규모 스윕 완료 — 창=[{Start}~{End}], fetch={Fetched}콜", JobName, startDate, today, fetched);
  375. }
  376. }